From a2744f0da77a017a53775263ead2ad8a5edd19db Mon Sep 17 00:00:00 2001 From: omer-vishlitzky Date: Thu, 6 Aug 2026 08:40:24 +0300 Subject: [PATCH 01/18] OSAC-3421: Add CaaS cluster mapper, state machine, and N+1 decomposition Implement ResourceMapper for ClusterOrder with CaaS-specific billing: - State machine: PROGRESSING/READY billable, FAILED/DELETING/DELETE_FAILED not - ErrSkipNonBillingTransition sentinel for billable<->billable and non-billable<->non-billable transitions - ClusterBillingDimensions with full components array (control plane + sorted worker node sets from spec) - DecomposeClusterComponents for N+1 fan-out at publish time - ChangedComponents for scaling detection (including removal with NodeCount=0) - ComponentEventID for deterministic adapter-level dedup - 58 new specs covering full state transition matrix, JSONB round-trip, decomposition, and scaling detection Assisted-by: Claude Code Signed-off-by: omer-vishlitzky --- .../internal/events/cluster.go | 315 ++++++++ .../internal/events/cluster_test.go | 694 ++++++++++++++++++ .../internal/events/mapper.go | 3 + .../internal/events/mapper_test.go | 4 +- 4 files changed, 1014 insertions(+), 2 deletions(-) create mode 100644 osac-metering/metering-service/internal/events/cluster.go create mode 100644 osac-metering/metering-service/internal/events/cluster_test.go diff --git a/osac-metering/metering-service/internal/events/cluster.go b/osac-metering/metering-service/internal/events/cluster.go new file mode 100644 index 000000000..dafc7e17f --- /dev/null +++ b/osac-metering/metering-service/internal/events/cluster.go @@ -0,0 +1,315 @@ +/* +Copyright (c) 2026 Red Hat, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ + +package events + +import ( + "errors" + "fmt" + "sort" + "strings" + "time" + + privatev1 "github.com/osac-project/osac-metering/internal/api/osac/private/v1" +) + +var ErrSkipNonBillingTransition = errors.New("non-billing transition") + +const ClusterStatePrefix = "CLUSTER_STATE_" + +type clusterMapper struct { + cl *privatev1.Cluster +} + +func (m *clusterMapper) ResourceType() string { return "cluster_order" } +func (m *clusterMapper) ResourceID() string { return m.cl.GetId() } + +func (m *clusterMapper) FulfillmentVersion() int32 { + if md := m.cl.GetMetadata(); md != nil { + return md.GetVersion() + } + return 0 +} + +func (m *clusterMapper) TenantID() string { + if md := m.cl.GetMetadata(); md != nil { + return md.GetTenant() + } + return "" +} + +func (m *clusterMapper) ProjectID() *string { + if md := m.cl.GetMetadata(); md != nil { + return NilIfEmpty(md.GetProject()) + } + return nil +} + +func (m *clusterMapper) CatalogItemID() *string { + if s := m.cl.GetSpec(); s != nil { + return NilIfEmpty(s.GetCatalogItem()) + } + return nil +} + +func (m *clusterMapper) TemplateID() *string { + if s := m.cl.GetSpec(); s != nil { + return NilIfEmpty(s.GetTemplate()) + } + return nil +} + +func (m *clusterMapper) CurrentState() string { + state := privatev1.ClusterState_CLUSTER_STATE_UNSPECIFIED + if s := m.cl.GetStatus(); s != nil { + state = s.GetState() + } + return strings.TrimPrefix(state.String(), ClusterStatePrefix) +} + +func (m *clusterMapper) IsBillable() bool { + return IsClusterBillableState(m.CurrentState()) +} + +func (m *clusterMapper) BillingDimensionsMap() map[string]any { + return ClusterBillingDimensions(m.cl) +} + +func (m *clusterMapper) CloudEventType(eventType privatev1.EventType, previousState string) (string, error) { + switch eventType { + case privatev1.EventType_EVENT_TYPE_OBJECT_CREATED: + return "osac.resource.created.v1", nil + case privatev1.EventType_EVENT_TYPE_OBJECT_DELETED: + return "osac.resource.deleted.v1", nil + case privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED: + return m.resolveUpdatedEventType(previousState) + default: + return "", fmt.Errorf("unsupported event type: %v", eventType) + } +} + +// CaaS billing model: both PROGRESSING and READY are billable. Transitions +// between them have no billing boundary — the interval continues. Dimension +// changes (scaling) during such transitions are detected by the Watch Consumer +// after receiving ErrSkipNonBillingTransition; it checks DimensionsEqual and +// emits updated.v1 for changed components. If the consumer misses a dimension +// change during PROGRESSING<->READY (unlikely — scaling during provisioning), +// the hourly reconciler detects billing_dimensions_drift and emits a +// correction event, bounding the gap to one reconciliation cycle. +func (m *clusterMapper) resolveUpdatedEventType(previousState string) (string, error) { + currentState := m.CurrentState() + currentBillable := IsClusterBillableState(currentState) + previousBillable := IsClusterBillableState(previousState) + + switch { + case currentBillable && previousState == "FAILED": + return "osac.resource.resumed.v1", nil + case currentBillable && previousState == "": + return "osac.resource.started.v1", nil + case !currentBillable && previousBillable: + return "osac.resource.suspended.v1", nil + case currentBillable && previousBillable: + return "", ErrSkipNonBillingTransition + case !currentBillable && !previousBillable: + return "", ErrSkipNonBillingTransition + default: + return "osac.resource.updated.v1", nil + } +} + +func (m *clusterMapper) TransitionTime(eventType privatev1.EventType) (time.Time, error) { + switch eventType { + case privatev1.EventType_EVENT_TYPE_OBJECT_CREATED: + if md := m.cl.GetMetadata(); md != nil { + if ct := md.GetCreationTimestamp(); ct != nil { + return ct.AsTime(), nil + } + } + return time.Time{}, fmt.Errorf("%w: cluster %s has no creation_timestamp", ErrDataQuality, m.cl.GetId()) + + case privatev1.EventType_EVENT_TYPE_OBJECT_DELETED: + if md := m.cl.GetMetadata(); md != nil { + if dt := md.GetDeletionTimestamp(); dt != nil { + return dt.AsTime(), nil + } + } + return time.Time{}, fmt.Errorf("%w: cluster %s has no deletion_timestamp", ErrDataQuality, m.cl.GetId()) + + default: + if s := m.cl.GetStatus(); s != nil { + if t := s.GetStateTransitionTime(); t != nil { + return t.AsTime(), nil + } + } + return time.Time{}, fmt.Errorf("%w: cluster %s has no state_transition_time", ErrDataQuality, m.cl.GetId()) + } +} + +// IsClusterBillableState returns whether a ClusterOrder state string represents +// a billable state. Single source of truth — used by Watch Consumer, Heartbeat +// Generator, and Reconciler. +func IsClusterBillableState(state string) bool { + return state == "PROGRESSING" || state == "READY" +} + +// ClusterBillingDimensions extracts billing dimensions from a Cluster proto, +// including the full component breakdown needed for N+1 decomposition. +// Node sets come from spec (desired state the tenant is billed for), not status. +func ClusterBillingDimensions(cl *privatev1.Cluster) map[string]any { + dims := map[string]any{} + spec := cl.GetSpec() + if spec == nil { + return dims + } + dims["cluster_template"] = spec.GetTemplate() + if ri := spec.GetReleaseImage(); ri != "" { + dims["release_image"] = ri + } + + // Use []any (not []map[string]any) so DecomposeClusterComponents' type + // assertion works for both fresh dims and JSONB-round-tripped dims. + components := []any{ + map[string]any{ + "component": "control_plane", + "host_type": "_control_plane", + "node_count": int32(1), + }, + } + + if nodeSets := spec.GetNodeSets(); nodeSets != nil { + keys := make([]string, 0, len(nodeSets)) + for k := range nodeSets { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + ns := nodeSets[k] + components = append(components, map[string]any{ + "component": "worker", + "host_type": ns.GetHostType(), + "node_count": ns.GetSize(), + }) + } + } + + dims["components"] = components + return dims +} + +// ComponentRecord represents one billing record in the N+1 decomposition. +type ComponentRecord struct { + Component string + HostType string + NodeCount int32 + ClusterTemplate string + ReleaseImage string +} + +// FlatBillingDimensions returns per-component billing dimensions for a single +// CloudEvent record. +func (cr ComponentRecord) FlatBillingDimensions() map[string]any { + dims := map[string]any{ + "cluster_template": cr.ClusterTemplate, + "component": cr.Component, + "host_type": cr.HostType, + "node_count": cr.NodeCount, + } + if cr.ReleaseImage != "" { + dims["release_image"] = cr.ReleaseImage + } + return dims +} + +// DecomposeClusterComponents extracts N+1 component records from stored +// billing dimensions. Used by Watch Consumer, Heartbeat Generator, and +// Reconciler to fan out one cluster into per-component events. +func DecomposeClusterComponents(billingDims map[string]any) []ComponentRecord { + clusterTemplate, _ := billingDims["cluster_template"].(string) + releaseImage, _ := billingDims["release_image"].(string) + + componentsRaw, ok := billingDims["components"] + if !ok { + return nil + } + + components, ok := componentsRaw.([]any) + if !ok { + return nil + } + + records := make([]ComponentRecord, 0, len(components)) + for _, c := range components { + cm, ok := c.(map[string]any) + if !ok { + continue + } + component, _ := cm["component"].(string) + hostType, _ := cm["host_type"].(string) + + var nodeCount int32 + if nc, ok := toFloat64(cm["node_count"]); ok { + nodeCount = int32(nc) + } + + records = append(records, ComponentRecord{ + Component: component, + HostType: hostType, + NodeCount: nodeCount, + ClusterTemplate: clusterTemplate, + ReleaseImage: releaseImage, + }) + } + + return records +} + +// ComponentEventID derives a deterministic CloudEvent ID for a decomposed +// component event. Deterministic IDs enable adapter-level dedup on replay. +func ComponentEventID(baseEventID string, comp ComponentRecord) string { + return fmt.Sprintf("%s/%s:%s", baseEventID, comp.Component, comp.HostType) +} + +// ChangedComponents compares old and new billing dimensions and returns +// component records that changed: node_count differs, newly added, or removed. +// Removed components are returned with NodeCount=0. +func ChangedComponents(oldDims, newDims map[string]any) []ComponentRecord { + oldRecords := DecomposeClusterComponents(oldDims) + newRecords := DecomposeClusterComponents(newDims) + + oldByKey := make(map[string]ComponentRecord, len(oldRecords)) + for _, r := range oldRecords { + oldByKey[r.Component+":"+r.HostType] = r + } + + newByKey := make(map[string]bool, len(newRecords)) + var changed []ComponentRecord + for _, r := range newRecords { + key := r.Component + ":" + r.HostType + newByKey[key] = true + old, exists := oldByKey[key] + if !exists || old.NodeCount != r.NodeCount { + changed = append(changed, r) + } + } + + for _, r := range oldRecords { + key := r.Component + ":" + r.HostType + if !newByKey[key] { + changed = append(changed, ComponentRecord{ + Component: r.Component, + HostType: r.HostType, + NodeCount: 0, + ClusterTemplate: r.ClusterTemplate, + ReleaseImage: r.ReleaseImage, + }) + } + } + + return changed +} diff --git a/osac-metering/metering-service/internal/events/cluster_test.go b/osac-metering/metering-service/internal/events/cluster_test.go new file mode 100644 index 000000000..8e4e7fafa --- /dev/null +++ b/osac-metering/metering-service/internal/events/cluster_test.go @@ -0,0 +1,694 @@ +package events_test + +import ( + "encoding/json" + "errors" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "google.golang.org/protobuf/types/known/timestamppb" + + privatev1 "github.com/osac-project/osac-metering/internal/api/osac/private/v1" + "github.com/osac-project/osac-metering/internal/events" +) + +func strPtr(s string) *string { return &s } + +var _ = Describe("CaaS Cluster Mapper", func() { + var cl *privatev1.Cluster + + BeforeEach(func() { + cl = &privatev1.Cluster{ + Id: "cluster-abc-123", + Metadata: &privatev1.Metadata{ + Tenant: "tenant-1", + Project: "project-alpha", + Version: 5, + CreationTimestamp: timestamppb.Now(), + }, + Spec: &privatev1.ClusterSpec{ + Template: "ocp-ci-small", + CatalogItem: "cluster-catalog-1", + ReleaseImage: strPtr("quay.io/openshift-release-dev/ocp-release:4.17.0-x86_64"), + NodeSets: map[string]*privatev1.ClusterNodeSet{ + "gpu-workers": {HostType: "gpu-h100", Size: 2}, + "cpu-workers": {HostType: "cpu-only", Size: 3}, + }, + }, + Status: &privatev1.ClusterStatus{ + State: privatev1.ClusterState_CLUSTER_STATE_PROGRESSING, + StateTransitionTime: timestamppb.Now(), + }, + } + }) + + Context("state machine — full transition matrix", func() { + DescribeTable("resolves correct CloudEvent type for state transitions", + func(currentState privatev1.ClusterState, previousState string, expectedType string, expectSkip bool) { + cl.Status.State = currentState + + event := &privatev1.Event{ + Id: "evt-1", + Type: privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, + Payload: &privatev1.Event_Cluster{Cluster: cl}, + } + + stateCtx := &events.StateContext{PreviousState: previousState} + ce, err := mapEvent(event, stateCtx) + + if expectSkip { + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, events.ErrSkipNonBillingTransition)).To(BeTrue()) + } else { + Expect(err).NotTo(HaveOccurred()) + Expect(ce.Type()).To(Equal(expectedType)) + } + }, + Entry("initial PROGRESSING (prev=empty) -> started.v1", + privatev1.ClusterState_CLUSTER_STATE_PROGRESSING, "", "osac.resource.started.v1", false), + Entry("initial READY (prev=empty) -> started.v1", + privatev1.ClusterState_CLUSTER_STATE_READY, "", "osac.resource.started.v1", false), + Entry("PROGRESSING -> READY -> skip (both billable)", + privatev1.ClusterState_CLUSTER_STATE_READY, "PROGRESSING", "", true), + Entry("READY -> PROGRESSING -> skip (both billable)", + privatev1.ClusterState_CLUSTER_STATE_PROGRESSING, "READY", "", true), + Entry("READY -> FAILED -> suspended.v1", + privatev1.ClusterState_CLUSTER_STATE_FAILED, "READY", "osac.resource.suspended.v1", false), + Entry("PROGRESSING -> FAILED -> suspended.v1", + privatev1.ClusterState_CLUSTER_STATE_FAILED, "PROGRESSING", "osac.resource.suspended.v1", false), + Entry("FAILED -> PROGRESSING -> resumed.v1", + privatev1.ClusterState_CLUSTER_STATE_PROGRESSING, "FAILED", "osac.resource.resumed.v1", false), + Entry("FAILED -> READY -> resumed.v1 (recovery direct to ready)", + privatev1.ClusterState_CLUSTER_STATE_READY, "FAILED", "osac.resource.resumed.v1", false), + Entry("READY -> DELETING -> suspended.v1", + privatev1.ClusterState_CLUSTER_STATE_DELETING, "READY", "osac.resource.suspended.v1", false), + Entry("PROGRESSING -> DELETING -> suspended.v1", + privatev1.ClusterState_CLUSTER_STATE_DELETING, "PROGRESSING", "osac.resource.suspended.v1", false), + Entry("DELETING -> DELETE_FAILED -> skip (both non-billable)", + privatev1.ClusterState_CLUSTER_STATE_DELETE_FAILED, "DELETING", "", true), + Entry("DELETE_FAILED -> DELETING -> skip (both non-billable)", + privatev1.ClusterState_CLUSTER_STATE_DELETING, "DELETE_FAILED", "", true), + Entry("FAILED -> DELETING -> skip (both non-billable)", + privatev1.ClusterState_CLUSTER_STATE_DELETING, "FAILED", "", true), + Entry("FAILED -> DELETE_FAILED -> skip (both non-billable)", + privatev1.ClusterState_CLUSTER_STATE_DELETE_FAILED, "FAILED", "", true), + ) + + It("maps OBJECT_CREATED to created.v1", func() { + event := &privatev1.Event{ + Id: "evt-create", + Type: privatev1.EventType_EVENT_TYPE_OBJECT_CREATED, + Payload: &privatev1.Event_Cluster{Cluster: cl}, + } + + ce, err := mapEvent(event, &events.StateContext{}) + Expect(err).NotTo(HaveOccurred()) + Expect(ce.Type()).To(Equal("osac.resource.created.v1")) + }) + + It("maps OBJECT_DELETED to deleted.v1", func() { + cl.Metadata.DeletionTimestamp = timestamppb.Now() + + event := &privatev1.Event{ + Id: "evt-delete", + Type: privatev1.EventType_EVENT_TYPE_OBJECT_DELETED, + Payload: &privatev1.Event_Cluster{Cluster: cl}, + } + + ce, err := mapEvent(event, &events.StateContext{}) + Expect(err).NotTo(HaveOccurred()) + Expect(ce.Type()).To(Equal("osac.resource.deleted.v1")) + }) + }) + + Context("resource mapper fields", func() { + It("returns resource_type=cluster_order", func() { + event := &privatev1.Event{ + Id: "evt-1", + Type: privatev1.EventType_EVENT_TYPE_OBJECT_CREATED, + Payload: &privatev1.Event_Cluster{Cluster: cl}, + } + + ce, err := mapEvent(event, &events.StateContext{}) + Expect(err).NotTo(HaveOccurred()) + + var data map[string]any + Expect(json.Unmarshal(ce.Data(), &data)).To(Succeed()) + Expect(data["resource_type"]).To(Equal("cluster_order")) + }) + + It("extracts resource_id from cluster ID", func() { + event := &privatev1.Event{ + Id: "evt-1", + Type: privatev1.EventType_EVENT_TYPE_OBJECT_CREATED, + Payload: &privatev1.Event_Cluster{Cluster: cl}, + } + + ce, err := mapEvent(event, &events.StateContext{}) + Expect(err).NotTo(HaveOccurred()) + Expect(ce.Extensions()["osacresourceid"]).To(Equal("cluster-abc-123")) + }) + + It("extracts tenant_id, project_id, template_id, catalog_item_id from metadata and spec", func() { + event := &privatev1.Event{ + Id: "evt-1", + Type: privatev1.EventType_EVENT_TYPE_OBJECT_CREATED, + Payload: &privatev1.Event_Cluster{Cluster: cl}, + } + + ce, err := mapEvent(event, &events.StateContext{}) + Expect(err).NotTo(HaveOccurred()) + + var data map[string]any + Expect(json.Unmarshal(ce.Data(), &data)).To(Succeed()) + Expect(data["tenant_id"]).To(Equal("tenant-1")) + Expect(data["project_id"]).To(Equal("project-alpha")) + Expect(data["template_id"]).To(Equal("ocp-ci-small")) + Expect(data["catalog_item_id"]).To(Equal("cluster-catalog-1")) + }) + + It("trims CLUSTER_STATE_ prefix from current_state", func() { + cl.Status.State = privatev1.ClusterState_CLUSTER_STATE_READY + event := &privatev1.Event{ + Id: "evt-1", + Type: privatev1.EventType_EVENT_TYPE_OBJECT_CREATED, + Payload: &privatev1.Event_Cluster{Cluster: cl}, + } + + ce, err := mapEvent(event, &events.StateContext{}) + Expect(err).NotTo(HaveOccurred()) + + var data map[string]any + Expect(json.Unmarshal(ce.Data(), &data)).To(Succeed()) + Expect(data["current_state"]).To(Equal("READY")) + }) + }) + + Context("billability", func() { + It("PROGRESSING is billable", func() { + Expect(events.IsClusterBillableState("PROGRESSING")).To(BeTrue()) + }) + + It("READY is billable", func() { + Expect(events.IsClusterBillableState("READY")).To(BeTrue()) + }) + + It("FAILED is not billable", func() { + Expect(events.IsClusterBillableState("FAILED")).To(BeFalse()) + }) + + It("DELETING is not billable", func() { + Expect(events.IsClusterBillableState("DELETING")).To(BeFalse()) + }) + + It("DELETE_FAILED is not billable", func() { + Expect(events.IsClusterBillableState("DELETE_FAILED")).To(BeFalse()) + }) + + It("UNSPECIFIED is not billable", func() { + Expect(events.IsClusterBillableState("UNSPECIFIED")).To(BeFalse()) + }) + }) + + Context("billing dimensions", func() { + It("includes cluster_template, release_image, and full components breakdown", func() { + dims := events.ClusterBillingDimensions(cl) + Expect(dims["cluster_template"]).To(Equal("ocp-ci-small")) + Expect(dims["release_image"]).To(Equal("quay.io/openshift-release-dev/ocp-release:4.17.0-x86_64")) + + components, ok := dims["components"].([]any) + Expect(ok).To(BeTrue(), "components must be []any for DecomposeClusterComponents compatibility") + Expect(components).To(HaveLen(3)) + + cp := components[0].(map[string]any) + Expect(cp["component"]).To(Equal("control_plane")) + Expect(cp["host_type"]).To(Equal("_control_plane")) + Expect(cp["node_count"]).To(Equal(int32(1))) + }) + + It("sorts worker node sets by key for deterministic ordering", func() { + dims := events.ClusterBillingDimensions(cl) + components := dims["components"].([]any) + + // control_plane first, then sorted by node set key: "cpu-workers" < "gpu-workers" + w1 := components[1].(map[string]any) + Expect(w1["host_type"]).To(Equal("cpu-only")) + Expect(w1["node_count"]).To(Equal(int32(3))) + w2 := components[2].(map[string]any) + Expect(w2["host_type"]).To(Equal("gpu-h100")) + Expect(w2["node_count"]).To(Equal(int32(2))) + }) + + It("omits release_image when nil", func() { + cl.Spec.ReleaseImage = nil + dims := events.ClusterBillingDimensions(cl) + Expect(dims).NotTo(HaveKey("release_image")) + }) + + It("handles nil spec gracefully", func() { + cl.Spec = nil + dims := events.ClusterBillingDimensions(cl) + Expect(dims).To(BeEmpty()) + }) + + It("handles nil node_sets with control plane only", func() { + cl.Spec.NodeSets = nil + dims := events.ClusterBillingDimensions(cl) + components := dims["components"].([]any) + Expect(components).To(HaveLen(1)) + cp := components[0].(map[string]any) + Expect(cp["component"]).To(Equal("control_plane")) + }) + + It("DecomposeClusterComponents works on fresh (non-JSONB) output", func() { + dims := events.ClusterBillingDimensions(cl) + records := events.DecomposeClusterComponents(dims) + Expect(records).To(HaveLen(3)) + Expect(records[0].Component).To(Equal("control_plane")) + Expect(records[0].NodeCount).To(Equal(int32(1))) + Expect(records[1].Component).To(Equal("worker")) + Expect(records[2].Component).To(Equal("worker")) + }) + }) + + Context("transition time", func() { + It("uses creation_timestamp for CREATED events", func() { + event := &privatev1.Event{ + Id: "evt-1", + Type: privatev1.EventType_EVENT_TYPE_OBJECT_CREATED, + Payload: &privatev1.Event_Cluster{Cluster: cl}, + } + + ce, err := mapEvent(event, &events.StateContext{}) + Expect(err).NotTo(HaveOccurred()) + Expect(ce.Time()).To(Equal(cl.Metadata.CreationTimestamp.AsTime())) + }) + + It("uses state_transition_time for UPDATED events", func() { + event := &privatev1.Event{ + Id: "evt-1", + Type: privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, + Payload: &privatev1.Event_Cluster{Cluster: cl}, + } + + ce, err := mapEvent(event, &events.StateContext{}) + Expect(err).NotTo(HaveOccurred()) + Expect(ce.Time()).To(Equal(cl.Status.StateTransitionTime.AsTime())) + }) + + It("rejects CREATED without creation_timestamp", func() { + cl.Metadata.CreationTimestamp = nil + event := &privatev1.Event{ + Id: "evt-1", + Type: privatev1.EventType_EVENT_TYPE_OBJECT_CREATED, + Payload: &privatev1.Event_Cluster{Cluster: cl}, + } + + _, err := mapEvent(event, &events.StateContext{}) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, events.ErrDataQuality)).To(BeTrue()) + }) + + It("rejects UPDATED without state_transition_time", func() { + cl.Status.StateTransitionTime = nil + event := &privatev1.Event{ + Id: "evt-1", + Type: privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, + Payload: &privatev1.Event_Cluster{Cluster: cl}, + } + + _, err := mapEvent(event, &events.StateContext{}) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, events.ErrDataQuality)).To(BeTrue()) + }) + }) + + Context("error handling", func() { + It("rejects events with empty resource_id", func() { + cl.Id = "" + event := &privatev1.Event{ + Id: "evt-1", + Type: privatev1.EventType_EVENT_TYPE_OBJECT_CREATED, + Payload: &privatev1.Event_Cluster{Cluster: cl}, + } + + _, err := mapEvent(event, &events.StateContext{}) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, events.ErrDataQuality)).To(BeTrue()) + }) + + It("rejects events with empty tenant_id", func() { + cl.Metadata.Tenant = "" + event := &privatev1.Event{ + Id: "evt-1", + Type: privatev1.EventType_EVENT_TYPE_OBJECT_CREATED, + Payload: &privatev1.Event_Cluster{Cluster: cl}, + } + + _, err := mapEvent(event, &events.StateContext{}) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, events.ErrDataQuality)).To(BeTrue()) + }) + }) +}) + +var _ = Describe("DecomposeClusterComponents", func() { + It("decomposes 1 control plane + 2 worker sets into 3 records", func() { + dims := map[string]any{ + "cluster_template": "ocp-ci-small", + "release_image": "quay.io/ocp:4.17.0", + "components": []any{ + map[string]any{"component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + map[string]any{"component": "worker", "host_type": "cpu-only", "node_count": int32(3)}, + map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, + }, + } + + records := events.DecomposeClusterComponents(dims) + Expect(records).To(HaveLen(3)) + + Expect(records[0].Component).To(Equal("control_plane")) + Expect(records[0].HostType).To(Equal("_control_plane")) + Expect(records[0].NodeCount).To(Equal(int32(1))) + Expect(records[0].ClusterTemplate).To(Equal("ocp-ci-small")) + Expect(records[0].ReleaseImage).To(Equal("quay.io/ocp:4.17.0")) + + Expect(records[1].Component).To(Equal("worker")) + Expect(records[1].HostType).To(Equal("cpu-only")) + Expect(records[1].NodeCount).To(Equal(int32(3))) + + Expect(records[2].Component).To(Equal("worker")) + Expect(records[2].HostType).To(Equal("gpu-h100")) + Expect(records[2].NodeCount).To(Equal(int32(2))) + }) + + It("handles node_count as float64 (JSONB round-trip)", func() { + dims := map[string]any{ + "cluster_template": "tmpl", + "components": []any{ + map[string]any{"component": "control_plane", "host_type": "_control_plane", "node_count": float64(1)}, + map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": float64(2)}, + }, + } + + records := events.DecomposeClusterComponents(dims) + Expect(records).To(HaveLen(2)) + Expect(records[0].NodeCount).To(Equal(int32(1))) + Expect(records[1].NodeCount).To(Equal(int32(2))) + }) + + It("returns nil when no components key", func() { + dims := map[string]any{"cluster_template": "tmpl"} + Expect(events.DecomposeClusterComponents(dims)).To(BeNil()) + }) + + It("returns nil for empty dims", func() { + Expect(events.DecomposeClusterComponents(map[string]any{})).To(BeNil()) + }) +}) + +var _ = Describe("ComponentRecord", func() { + It("produces flat billing dimensions", func() { + cr := events.ComponentRecord{ + Component: "worker", + HostType: "gpu-h100", + NodeCount: 2, + ClusterTemplate: "ocp-ci-small", + ReleaseImage: "quay.io/ocp:4.17.0", + } + + flat := cr.FlatBillingDimensions() + Expect(flat["cluster_template"]).To(Equal("ocp-ci-small")) + Expect(flat["release_image"]).To(Equal("quay.io/ocp:4.17.0")) + Expect(flat["component"]).To(Equal("worker")) + Expect(flat["host_type"]).To(Equal("gpu-h100")) + Expect(flat["node_count"]).To(Equal(int32(2))) + }) + + It("omits release_image when empty", func() { + cr := events.ComponentRecord{ + Component: "control_plane", + HostType: "_control_plane", + NodeCount: 1, + ClusterTemplate: "tmpl", + } + + flat := cr.FlatBillingDimensions() + Expect(flat).NotTo(HaveKey("release_image")) + }) +}) + +var _ = Describe("ComponentEventID", func() { + It("produces deterministic IDs", func() { + comp := events.ComponentRecord{Component: "worker", HostType: "gpu-h100"} + id1 := events.ComponentEventID("evt-123", comp) + id2 := events.ComponentEventID("evt-123", comp) + Expect(id1).To(Equal(id2)) + Expect(id1).To(Equal("evt-123/worker:gpu-h100")) + }) + + It("produces different IDs for different components", func() { + cp := events.ComponentRecord{Component: "control_plane", HostType: "_control_plane"} + worker := events.ComponentRecord{Component: "worker", HostType: "gpu-h100"} + Expect(events.ComponentEventID("evt-1", cp)).NotTo(Equal(events.ComponentEventID("evt-1", worker))) + }) + + It("produces different IDs for different base events", func() { + comp := events.ComponentRecord{Component: "worker", HostType: "gpu-h100"} + Expect(events.ComponentEventID("evt-1", comp)).NotTo(Equal(events.ComponentEventID("evt-2", comp))) + }) +}) + +var _ = Describe("ChangedComponents", func() { + It("detects node_count change in a single worker set", func() { + oldDims := map[string]any{ + "cluster_template": "tmpl", + "components": []any{ + map[string]any{"component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, + }, + } + newDims := map[string]any{ + "cluster_template": "tmpl", + "components": []any{ + map[string]any{"component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": int32(4)}, + }, + } + + changed := events.ChangedComponents(oldDims, newDims) + Expect(changed).To(HaveLen(1)) + Expect(changed[0].HostType).To(Equal("gpu-h100")) + Expect(changed[0].NodeCount).To(Equal(int32(4))) + }) + + It("returns empty when nothing changed", func() { + dims := map[string]any{ + "cluster_template": "tmpl", + "components": []any{ + map[string]any{"component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + }, + } + + Expect(events.ChangedComponents(dims, dims)).To(BeEmpty()) + }) + + It("detects newly added worker node set", func() { + oldDims := map[string]any{ + "cluster_template": "tmpl", + "components": []any{ + map[string]any{"component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + }, + } + newDims := map[string]any{ + "cluster_template": "tmpl", + "components": []any{ + map[string]any{"component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, + }, + } + + changed := events.ChangedComponents(oldDims, newDims) + Expect(changed).To(HaveLen(1)) + Expect(changed[0].HostType).To(Equal("gpu-h100")) + }) + + It("detects removed worker node set with NodeCount=0", func() { + oldDims := map[string]any{ + "cluster_template": "tmpl", + "components": []any{ + map[string]any{"component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, + }, + } + newDims := map[string]any{ + "cluster_template": "tmpl", + "components": []any{ + map[string]any{"component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + }, + } + + changed := events.ChangedComponents(oldDims, newDims) + Expect(changed).To(HaveLen(1)) + Expect(changed[0].HostType).To(Equal("gpu-h100")) + Expect(changed[0].NodeCount).To(Equal(int32(0))) + }) + + It("handles int32 vs float64 from JSONB round-trip", func() { + oldDims := map[string]any{ + "cluster_template": "tmpl", + "components": []any{ + map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, + }, + } + newDims := map[string]any{ + "cluster_template": "tmpl", + "components": []any{ + map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": float64(2)}, + }, + } + + Expect(events.ChangedComponents(oldDims, newDims)).To(BeEmpty()) + }) +}) + +var _ = Describe("DimensionsEqual with nested CaaS components", func() { + It("matches identical billing dimensions with components array", func() { + a := map[string]any{ + "cluster_template": "ocp-ci-small", + "release_image": "quay.io/ocp:4.17.0", + "components": []any{ + map[string]any{"component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, + }, + } + b := map[string]any{ + "cluster_template": "ocp-ci-small", + "release_image": "quay.io/ocp:4.17.0", + "components": []any{ + map[string]any{"component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, + }, + } + Expect(events.DimensionsEqual(a, b)).To(BeTrue()) + }) + + It("detects node_count change in components array", func() { + a := map[string]any{ + "cluster_template": "tmpl", + "components": []any{ + map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, + }, + } + b := map[string]any{ + "cluster_template": "tmpl", + "components": []any{ + map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": int32(4)}, + }, + } + Expect(events.DimensionsEqual(a, b)).To(BeFalse()) + }) + + It("detects different number of components", func() { + a := map[string]any{ + "cluster_template": "tmpl", + "components": []any{ + map[string]any{"component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + }, + } + b := map[string]any{ + "cluster_template": "tmpl", + "components": []any{ + map[string]any{"component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, + }, + } + Expect(events.DimensionsEqual(a, b)).To(BeFalse()) + }) + + It("handles JSONB round-trip: int32 stored, float64 on read", func() { + stored := map[string]any{ + "cluster_template": "tmpl", + "release_image": "quay.io/ocp:4.17.0", + "components": []any{ + map[string]any{"component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, + }, + } + + // Simulate JSONB round-trip: marshal then unmarshal + data, err := json.Marshal(stored) + Expect(err).NotTo(HaveOccurred()) + + var roundTripped map[string]any + Expect(json.Unmarshal(data, &roundTripped)).To(Succeed()) + + // After JSON round-trip: int32 becomes float64, []map becomes []any + Expect(events.DimensionsEqual(stored, roundTripped)).To(BeTrue()) + }) + + It("detects node_count change after JSONB round-trip", func() { + stored := map[string]any{ + "cluster_template": "tmpl", + "components": []any{ + map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, + }, + } + data, err := json.Marshal(stored) + Expect(err).NotTo(HaveOccurred()) + + var roundTripped map[string]any + Expect(json.Unmarshal(data, &roundTripped)).To(Succeed()) + + // Change node_count in the incoming (non-round-tripped) version + incoming := map[string]any{ + "cluster_template": "tmpl", + "components": []any{ + map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": int32(4)}, + }, + } + + Expect(events.DimensionsEqual(roundTripped, incoming)).To(BeFalse()) + }) + + It("round-trip preserves equality for multi-component clusters", func() { + original := map[string]any{ + "cluster_template": "ocp-ci-small", + "release_image": "quay.io/ocp:4.17.0", + "components": []any{ + map[string]any{"component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + map[string]any{"component": "worker", "host_type": "cpu-only", "node_count": int32(3)}, + map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, + }, + } + + data, err := json.Marshal(original) + Expect(err).NotTo(HaveOccurred()) + var rt1 map[string]any + Expect(json.Unmarshal(data, &rt1)).To(Succeed()) + + // Round-trip again to simulate double-read + data2, err := json.Marshal(rt1) + Expect(err).NotTo(HaveOccurred()) + var rt2 map[string]any + Expect(json.Unmarshal(data2, &rt2)).To(Succeed()) + + Expect(events.DimensionsEqual(rt1, rt2)).To(BeTrue()) + }) + + It("detects host_type change within components", func() { + a := map[string]any{ + "cluster_template": "tmpl", + "components": []any{ + map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, + }, + } + b := map[string]any{ + "cluster_template": "tmpl", + "components": []any{ + map[string]any{"component": "worker", "host_type": "gpu-a100", "node_count": int32(2)}, + }, + } + Expect(events.DimensionsEqual(a, b)).To(BeFalse()) + }) +}) diff --git a/osac-metering/metering-service/internal/events/mapper.go b/osac-metering/metering-service/internal/events/mapper.go index a03bb6942..c0c34ecf2 100644 --- a/osac-metering/metering-service/internal/events/mapper.go +++ b/osac-metering/metering-service/internal/events/mapper.go @@ -116,6 +116,9 @@ func mapperForEvent(event *privatev1.Event) (ResourceMapper, error) { if ci := event.GetComputeInstance(); ci != nil { return &computeInstanceMapper{ci: ci}, nil } + if cl := event.GetCluster(); cl != nil { + return &clusterMapper{cl: cl}, nil + } return nil, fmt.Errorf("unsupported event payload type for event %s", event.GetId()) } diff --git a/osac-metering/metering-service/internal/events/mapper_test.go b/osac-metering/metering-service/internal/events/mapper_test.go index cd569a03d..0ad97d63c 100644 --- a/osac-metering/metering-service/internal/events/mapper_test.go +++ b/osac-metering/metering-service/internal/events/mapper_test.go @@ -545,11 +545,11 @@ var _ = Describe("MapWatchEvent", func() { Expect(err.Error()).To(ContainSubstring("unsupported")) }) - It("returns an error for non-ComputeInstance payload", func() { + It("returns an error for unsupported payload type", func() { event := &privatev1.Event{ Id: "evt-1", Type: privatev1.EventType_EVENT_TYPE_OBJECT_CREATED, - Payload: &privatev1.Event_Cluster{Cluster: &privatev1.Cluster{}}, + Payload: &privatev1.Event_Hub{Hub: &privatev1.Hub{}}, } _, err := mapEvent(event, &events.StateContext{}) From a419d79ac51d600ce5847612955812924f6697e1 Mon Sep 17 00:00:00 2001 From: omer-vishlitzky Date: Thu, 6 Aug 2026 08:40:36 +0300 Subject: [PATCH 02/18] OSAC-3421: Integrate CaaS into Watch Consumer with N+1 publish and scaling - Extend Watch filter to include cluster events - ErrSkipNonBillingTransition handling: update projection on non-billing transitions, detect dimension changes for scaling events - publishLifecycleEvents: single event for VMaaS/created/deleted, N+1 decomposition for CaaS lifecycle events - handleScalingEvent: emit updated.v1 only for changed components, update projection even when only non-component dims change - Deterministic CloudEvent IDs via ComponentEventID for adapter dedup Assisted-by: Claude Code Signed-off-by: omer-vishlitzky --- .../internal/events/cluster.go | 16 +- .../internal/watch/consumer.go | 139 +++++++++++++++++- .../internal/watch/consumer_test.go | 4 +- 3 files changed, 148 insertions(+), 11 deletions(-) diff --git a/osac-metering/metering-service/internal/events/cluster.go b/osac-metering/metering-service/internal/events/cluster.go index dafc7e17f..5a0a24dec 100644 --- a/osac-metering/metering-service/internal/events/cluster.go +++ b/osac-metering/metering-service/internal/events/cluster.go @@ -53,14 +53,18 @@ func (m *clusterMapper) ProjectID() *string { func (m *clusterMapper) CatalogItemID() *string { if s := m.cl.GetSpec(); s != nil { - return NilIfEmpty(s.GetCatalogItem()) + if ci := s.GetCatalogItem(); ci != nil { + return NilIfEmpty(ci.GetId()) + } } return nil } func (m *clusterMapper) TemplateID() *string { if s := m.cl.GetSpec(); s != nil { - return NilIfEmpty(s.GetTemplate()) + if t := s.GetTemplate(); t != nil { + return NilIfEmpty(t.GetId()) + } } return nil } @@ -167,9 +171,11 @@ func ClusterBillingDimensions(cl *privatev1.Cluster) map[string]any { if spec == nil { return dims } - dims["cluster_template"] = spec.GetTemplate() - if ri := spec.GetReleaseImage(); ri != "" { - dims["release_image"] = ri + if t := spec.GetTemplate(); t != nil { + dims["cluster_template"] = t.GetName() + } + if vn := spec.GetVersionName(); vn != "" { + dims["version_name"] = vn } // Use []any (not []map[string]any) so DecomposeClusterComponents' type diff --git a/osac-metering/metering-service/internal/watch/consumer.go b/osac-metering/metering-service/internal/watch/consumer.go index 4268f8343..5f82ee385 100644 --- a/osac-metering/metering-service/internal/watch/consumer.go +++ b/osac-metering/metering-service/internal/watch/consumer.go @@ -35,7 +35,7 @@ const ( defaultInitialDelay = 1 * time.Second defaultMaxDelay = 30 * time.Second defaultHandlerRetries = 3 - computeInstanceFilter = "has(event.compute_instance)" + meteringFilter = "has(event.compute_instance) || has(event.cluster)" ) // Consumer connects to the fulfillment-service gRPC Watch stream, maps @@ -94,7 +94,7 @@ func (c *Consumer) Run(ctx context.Context) error { } func (c *Consumer) consumeStream(ctx context.Context) (int, error) { - filter := computeInstanceFilter + filter := meteringFilter stream, err := c.client.Watch(ctx, &privatev1.EventsWatchRequest{ Filter: &filter, }) @@ -153,13 +153,25 @@ func (c *Consumer) handleEvent(ctx context.Context, event *privatev1.Event) erro if errors.Is(err, events.ErrTransientState) { return c.handleTransientState(ctx, mapper, existing, version, transitionTime) } + if errors.Is(err, events.ErrSkipNonBillingTransition) { + if existing != nil && !events.DimensionsEqual(existing.BillingDimensions, dims) { + return c.handleScalingEvent(ctx, event, mapper, existing, transitionTime, version, currentState, isBillable, dims) + } + c.logger.V(1).Info("non-billing state transition, updating projection only", + "resource_id", resourceID, "state", currentState) + projState := c.buildProjectionState(mapper, existing, transitionTime, version, currentState, isBillable, dims) + if upsertErr := c.store.Upsert(ctx, projState); upsertErr != nil && !errors.Is(upsertErr, projection.ErrStaleVersion) { + return fmt.Errorf("upserting projection for %s: %w", resourceID, upsertErr) + } + return nil + } return err } projState := c.buildProjectionState(mapper, existing, transitionTime, version, currentState, isBillable, dims) if event.GetType() == privatev1.EventType_EVENT_TYPE_OBJECT_DELETED { - if err := c.publishWithRetry(ctx, ce); err != nil { + if err := c.publishLifecycleEvents(ctx, ce, mapper, event.GetId()); err != nil { return err } if existing != nil { @@ -180,7 +192,7 @@ func (c *Consumer) handleEvent(ctx context.Context, event *privatev1.Event) erro return fmt.Errorf("upserting projection for %s: %w", resourceID, err) } - if err := c.publishWithRetry(ctx, ce); err != nil { + if err := c.publishLifecycleEvents(ctx, ce, mapper, event.GetId()); err != nil { return err } @@ -221,6 +233,125 @@ func (c *Consumer) handleTransientState( return nil } +func (c *Consumer) publishLifecycleEvents(ctx context.Context, baseCE *cloudevents.Event, mapper events.ResourceMapper, eventID string) error { + if mapper.ResourceType() != "cluster_order" || + baseCE.Type() == "osac.resource.created.v1" || + baseCE.Type() == "osac.resource.deleted.v1" { + return c.publishWithRetry(ctx, baseCE) + } + + components := events.DecomposeClusterComponents(mapper.BillingDimensionsMap()) + if len(components) == 0 { + return c.publishWithRetry(ctx, baseCE) + } + + for _, comp := range components { + compCE, ceErr := c.buildComponentEvent(baseCE, eventID, comp) + if ceErr != nil { + return ceErr + } + if err := c.publishWithRetry(ctx, &compCE); err != nil { + return err + } + } + return nil +} + +func (c *Consumer) handleScalingEvent(ctx context.Context, event *privatev1.Event, mapper events.ResourceMapper, existing *projection.ResourceState, transitionTime time.Time, version int32, currentState string, isBillable bool, dims map[string]any) error { + resourceID := mapper.ResourceID() + changed := events.ChangedComponents(existing.BillingDimensions, dims) + + projState := c.buildProjectionState(mapper, existing, transitionTime, version, currentState, isBillable, dims) + if err := c.store.Upsert(ctx, projState); err != nil { + if errors.Is(err, projection.ErrStaleVersion) { + c.logger.Info("stale version during scaling, skipping", "resource_id", resourceID) + return nil + } + return fmt.Errorf("upserting projection for scaling %s: %w", resourceID, err) + } + + if len(changed) == 0 { + c.logger.V(1).Info("non-component dimension change, projection updated", + "resource_id", resourceID) + return nil + } + + stateCtx := c.buildStateContext(existing, isBillable, transitionTime, dims) + for _, comp := range changed { + ce, ceErr := c.buildScalingEvent(event.GetId(), mapper, comp, stateCtx, transitionTime) + if ceErr != nil { + return ceErr + } + if err := c.publishWithRetry(ctx, &ce); err != nil { + return err + } + } + + c.logger.Info("published scaling events", + "resource_id", resourceID, "changed_components", len(changed)) + return nil +} + +func (c *Consumer) buildComponentEvent(baseCE *cloudevents.Event, eventID string, comp events.ComponentRecord) (cloudevents.Event, error) { + ce := cloudevents.NewEvent() + ce.SetID(events.ComponentEventID(eventID, comp)) + ce.SetSource(baseCE.Source()) + ce.SetType(baseCE.Type()) + ce.SetTime(baseCE.Time()) + + for k, v := range baseCE.Extensions() { + ce.SetExtension(k, v) + } + + var baseData map[string]any + if err := baseCE.DataAs(&baseData); err != nil { + return ce, fmt.Errorf("reading base event data: %w", err) + } + + baseData["billing_dimensions"] = comp.FlatBillingDimensions() + if err := ce.SetData(cloudevents.ApplicationJSON, baseData); err != nil { + return ce, fmt.Errorf("setting component event data: %w", err) + } + return ce, nil +} + +func (c *Consumer) buildScalingEvent(eventID string, mapper events.ResourceMapper, comp events.ComponentRecord, stateCtx *events.StateContext, transitionTime time.Time) (cloudevents.Event, error) { + ce := cloudevents.NewEvent() + ce.SetID(events.ComponentEventID(eventID, comp)) + ce.SetSource("osac-metering") + ce.SetType("osac.resource.updated.v1") + ce.SetTime(transitionTime) + + projectID := "" + if p := mapper.ProjectID(); p != nil { + projectID = *p + } + events.SetOSACExtensions(&ce, mapper.ResourceID(), mapper.ResourceType(), mapper.TenantID(), projectID) + + var prevStatePtr *string + if stateCtx.PreviousState != "" { + prevStatePtr = &stateCtx.PreviousState + } + + data := map[string]any{ + "resource_id": mapper.ResourceID(), + "resource_type": mapper.ResourceType(), + "tenant_id": mapper.TenantID(), + "project_id": mapper.ProjectID(), + "catalog_item_id": mapper.CatalogItemID(), + "template_id": mapper.TemplateID(), + "previous_state": prevStatePtr, + "current_state": mapper.CurrentState(), + "transition_time": transitionTime.Format(time.RFC3339Nano), + "duration_seconds": stateCtx.DurationSeconds, + "billing_dimensions": comp.FlatBillingDimensions(), + "schema_version": "v1", + } + if err := ce.SetData(cloudevents.ApplicationJSON, data); err != nil { + return ce, fmt.Errorf("setting scaling event data: %w", err) + } + return ce, nil +} func (c *Consumer) shouldSkipUpdate(event *privatev1.Event, existing *projection.ResourceState, currentState string, dims map[string]any, transitionTime time.Time, resourceID string) bool { if event.GetType() != privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED || existing == nil { return false diff --git a/osac-metering/metering-service/internal/watch/consumer_test.go b/osac-metering/metering-service/internal/watch/consumer_test.go index 5a44da8c9..a436611f6 100644 --- a/osac-metering/metering-service/internal/watch/consumer_test.go +++ b/osac-metering/metering-service/internal/watch/consumer_test.go @@ -372,7 +372,7 @@ var _ = Describe("Consumer", func() { Expect(pub.published[0].ID()).To(Equal("good-evt")) }) - It("sets compute_instance filter on watch request", func() { + It("sets metering filter on watch request", func() { blockingStream := &mockWatchStream{ctx: ctx} client.results = []mockStreamResult{{stream: blockingStream}} @@ -394,7 +394,7 @@ var _ = Describe("Consumer", func() { client.mu.Lock() defer client.mu.Unlock() Expect(client.calls).ToNot(BeEmpty()) - Expect(client.calls[0].GetFilter()).To(Equal("has(event.compute_instance)")) + Expect(client.calls[0].GetFilter()).To(Equal("has(event.compute_instance) || has(event.cluster)")) }) It("fails fast on unknown payload type and reconnects", func() { From 6a1b21fea8d6238c3c3c30a67671e5153a82a600 Mon Sep 17 00:00:00 2001 From: omer-vishlitzky Date: Thu, 6 Aug 2026 08:40:48 +0300 Subject: [PATCH 03/18] OSAC-3433: Add CaaS heartbeat N+1 and reconciliation with ClusterLister Heartbeat Generator: - buildHeartbeatEvents returns 1 event for VMaaS, N+1 for cluster_order - Per-component heartbeats with flat billing dimensions Reconciler: - Add ClusterLister interface and loadClusters pagination - isBillableForType dispatch for per-resource-type billability - Generalize hardcoded "compute_instance" strings to fs.resourceType - N+1 decomposition for correction events via buildCorrectionEvents - N+1 synthetic heartbeats via buildSyntheticHeartbeats - Guard against nil clusterClient in missed deletion detection - Wire ClustersClient in main.go Assisted-by: Claude Code Signed-off-by: omer-vishlitzky --- .../cmd/metering-service/main.go | 3 +- .../internal/heartbeat/generator.go | 75 ++++++- .../internal/reconciliation/correction.go | 32 +++ .../internal/reconciliation/reconciler.go | 189 ++++++++++++++---- .../reconciliation/reconciler_test.go | 32 +-- 5 files changed, 264 insertions(+), 67 deletions(-) diff --git a/osac-metering/metering-service/cmd/metering-service/main.go b/osac-metering/metering-service/cmd/metering-service/main.go index cf2c518ae..8e02f8907 100644 --- a/osac-metering/metering-service/cmd/metering-service/main.go +++ b/osac-metering/metering-service/cmd/metering-service/main.go @@ -206,7 +206,8 @@ func run(ctx context.Context, logger logr.Logger, cfg *config) error { publisher := kafkapub.NewPublisher(producer) computeClient := privatev1.NewComputeInstancesClient(grpcConn) - reconciler := reconciliation.NewReconciler(computeClient, store, publisher, logger, cfg.heartbeatInterval) + clusterClient := privatev1.NewClustersClient(grpcConn) + reconciler := reconciliation.NewReconciler(computeClient, clusterClient, store, publisher, logger, cfg.heartbeatInterval) logger.Info("running startup reconciliation") if err := reconciler.Reconcile(ctx); err != nil { diff --git a/osac-metering/metering-service/internal/heartbeat/generator.go b/osac-metering/metering-service/internal/heartbeat/generator.go index 007e9852a..33d144db3 100644 --- a/osac-metering/metering-service/internal/heartbeat/generator.go +++ b/osac-metering/metering-service/internal/heartbeat/generator.go @@ -96,20 +96,22 @@ func (g *Generator) tick(ctx context.Context) error { // to prevent duplicate heartbeats on retry. At scale (>10K VMs), consider // Kafka transactional producer for atomic batch publish. for i := range billable { - ce, ceErr := g.buildHeartbeatEvent(&billable[i], now) + hbEvents, ceErr := g.buildHeartbeatEvents(&billable[i], now) if ceErr != nil { g.logger.Error(ceErr, "building heartbeat event, skipping resource", "resource_id", billable[i].ResourceID) continue } - if err := g.publisher.Publish(ctx, ce); err != nil { - if len(publishedIDs) > 0 { - if cpErr := g.store.UpdateLastHeartbeat(ctx, publishedIDs, now); cpErr != nil { - g.logger.Error(cpErr, "failed to checkpoint partial heartbeat progress", - "published", len(publishedIDs)) + for j := range hbEvents { + if err := g.publisher.Publish(ctx, hbEvents[j]); err != nil { + if len(publishedIDs) > 0 { + if cpErr := g.store.UpdateLastHeartbeat(ctx, publishedIDs, now); cpErr != nil { + g.logger.Error(cpErr, "failed to checkpoint partial heartbeat progress", + "published", len(publishedIDs)) + } } + return fmt.Errorf("publishing heartbeat for %s: %w", billable[i].ResourceID, err) } - return fmt.Errorf("publishing heartbeat for %s: %w", billable[i].ResourceID, err) } publishedIDs = append(publishedIDs, billable[i].ResourceID) } @@ -122,6 +124,65 @@ func (g *Generator) tick(ctx context.Context) error { return nil } +func (g *Generator) buildHeartbeatEvents(state *projection.ResourceState, now time.Time) ([]cloudevents.Event, error) { + if state.ResourceType != "cluster_order" { + ce, err := g.buildHeartbeatEvent(state, now) + if err != nil { + return nil, err + } + return []cloudevents.Event{ce}, nil + } + + components := events.DecomposeClusterComponents(state.BillingDimensions) + if len(components) == 0 { + ce, err := g.buildHeartbeatEvent(state, now) + if err != nil { + return nil, err + } + return []cloudevents.Event{ce}, nil + } + + result := make([]cloudevents.Event, 0, len(components)) + for _, comp := range components { + ce, err := g.buildComponentHeartbeat(state, comp, now) + if err != nil { + return nil, err + } + result = append(result, ce) + } + return result, nil +} + +func (g *Generator) buildComponentHeartbeat(state *projection.ResourceState, comp events.ComponentRecord, now time.Time) (cloudevents.Event, error) { + ce := cloudevents.NewEvent() + ce.SetID(events.ComponentEventID(uuid.NewString(), comp)) + ce.SetSource("osac-metering") + ce.SetType("osac.resource.heartbeat.v1") + ce.SetTime(now) + + events.SetOSACExtensions(&ce, state.ResourceID, state.ResourceType, state.TenantID, state.ProjectID) + + var durationSeconds float64 + if state.BillableSince != nil { + durationSeconds = now.Sub(*state.BillableSince).Seconds() + } + + data := heartbeatData{ + ResourceID: state.ResourceID, + ResourceType: state.ResourceType, + TenantID: state.TenantID, + ProjectID: events.NilIfEmpty(state.ProjectID), + CurrentState: state.CurrentState, + DurationSeconds: durationSeconds, + BillingDimensions: comp.FlatBillingDimensions(), + SchemaVersion: "v1", + } + if err := ce.SetData(cloudevents.ApplicationJSON, data); err != nil { + return ce, fmt.Errorf("setting component heartbeat data: %w", err) + } + return ce, nil +} + func (g *Generator) buildHeartbeatEvent(state *projection.ResourceState, now time.Time) (cloudevents.Event, error) { ce := cloudevents.NewEvent() ce.SetID(uuid.NewString()) diff --git a/osac-metering/metering-service/internal/reconciliation/correction.go b/osac-metering/metering-service/internal/reconciliation/correction.go index 251832ee3..954882cda 100644 --- a/osac-metering/metering-service/internal/reconciliation/correction.go +++ b/osac-metering/metering-service/internal/reconciliation/correction.go @@ -65,6 +65,38 @@ func correctionDescription(reason CorrectionReason) string { } } +func buildCorrectionEvents( + resourceID, resourceType, tenantID, projectID string, + reason CorrectionReason, + projectionState, sourceState string, + billingDimensions map[string]any, + interval *AffectedInterval, + now time.Time, +) ([]cloudevents.Event, error) { + if resourceType == "cluster_order" { + components := events.DecomposeClusterComponents(billingDimensions) + if len(components) > 0 { + result := make([]cloudevents.Event, 0, len(components)) + for _, comp := range components { + ce, err := buildCorrectionEvent(resourceID, resourceType, tenantID, projectID, + reason, projectionState, sourceState, comp.FlatBillingDimensions(), interval, now) + if err != nil { + return nil, err + } + ce.SetID(events.ComponentEventID(ce.ID(), comp)) + result = append(result, ce) + } + return result, nil + } + } + ce, err := buildCorrectionEvent(resourceID, resourceType, tenantID, projectID, + reason, projectionState, sourceState, billingDimensions, interval, now) + if err != nil { + return nil, err + } + return []cloudevents.Event{ce}, nil +} + func buildCorrectionEvent( resourceID, resourceType, tenantID, projectID string, reason CorrectionReason, diff --git a/osac-metering/metering-service/internal/reconciliation/reconciler.go b/osac-metering/metering-service/internal/reconciliation/reconciler.go index 74d0e0734..a08e25315 100644 --- a/osac-metering/metering-service/internal/reconciliation/reconciler.go +++ b/osac-metering/metering-service/internal/reconciliation/reconciler.go @@ -53,8 +53,13 @@ type ComputeInstanceLister interface { List(ctx context.Context, in *privatev1.ComputeInstancesListRequest, opts ...grpc.CallOption) (*privatev1.ComputeInstancesListResponse, error) } +type ClusterLister interface { + List(ctx context.Context, in *privatev1.ClustersListRequest, opts ...grpc.CallOption) (*privatev1.ClustersListResponse, error) +} + type Reconciler struct { computeClient ComputeInstanceLister + clusterClient ClusterLister store projection.Store publisher kafkapub.EventPublisher logger logr.Logger @@ -63,6 +68,7 @@ type Reconciler struct { func NewReconciler( computeClient ComputeInstanceLister, + clusterClient ClusterLister, store projection.Store, publisher kafkapub.EventPublisher, logger logr.Logger, @@ -70,6 +76,7 @@ func NewReconciler( ) *Reconciler { return &Reconciler{ computeClient: computeClient, + clusterClient: clusterClient, store: store, publisher: publisher, logger: logger, @@ -129,27 +136,37 @@ func (r *Reconciler) Reconcile(ctx context.Context) error { return nil } +func (r *Reconciler) publishCorrections(ctx context.Context, id, resourceType, tenantID, projectID string, reason CorrectionReason, projState, sourceState string, dims map[string]any, now time.Time) error { + ces, err := buildCorrectionEvents(id, resourceType, tenantID, projectID, + reason, projState, sourceState, dims, nil, now) + if err != nil { + return fmt.Errorf("building %s event for %s: %w", reason, id, err) + } + for _, ce := range ces { + if err := r.publisher.Publish(ctx, ce); err != nil { + return fmt.Errorf("publishing %s for %s: %w", reason, id, err) + } + } + reconCorrections.WithLabelValues(string(reason), resourceType).Inc() + return nil +} + func (r *Reconciler) reconcileFulfillmentResources(ctx context.Context, fulfillmentState map[string]fulfillmentResource, projMap map[string]projection.ResourceState, now time.Time) (int, error) { corrections := 0 for id, fs := range fulfillmentState { ps, exists := projMap[id] if !exists { - ce, ceErr := buildCorrectionEvent(id, "compute_instance", fs.tenantID, fs.projectID, - MissedCreation, "", fs.state, fs.billingDimensions, nil, now) - if ceErr != nil { - return corrections, fmt.Errorf("building missed_creation event for %s: %w", id, ceErr) + if err := r.publishCorrections(ctx, id, fs.resourceType, fs.tenantID, fs.projectID, + MissedCreation, "", fs.state, fs.billingDimensions, now); err != nil { + return corrections, err } - if err := r.publisher.Publish(ctx, ce); err != nil { - return corrections, fmt.Errorf("publishing missed_creation for %s: %w", id, err) - } - reconCorrections.WithLabelValues(string(MissedCreation), "compute_instance").Inc() corrections++ - isBillable := events.IsBillableState(fs.state) + isBillable := isBillableForType(fs.resourceType, fs.state) newState := projection.ResourceState{ ResourceID: id, - ResourceType: "compute_instance", + ResourceType: fs.resourceType, TenantID: fs.tenantID, ProjectID: fs.projectID, CurrentState: fs.state, @@ -190,18 +207,13 @@ func (r *Reconciler) reconcileFulfillmentResources(ctx context.Context, fulfillm } if ps.CurrentState != fs.state { - ce, ceErr := buildCorrectionEvent(id, "compute_instance", fs.tenantID, fs.projectID, - StateDrift, ps.CurrentState, fs.state, fs.billingDimensions, nil, now) - if ceErr != nil { - return corrections, fmt.Errorf("building state_drift event for %s: %w", id, ceErr) - } - if err := r.publisher.Publish(ctx, ce); err != nil { - return corrections, fmt.Errorf("publishing state_drift for %s: %w", id, err) + if err := r.publishCorrections(ctx, id, fs.resourceType, fs.tenantID, fs.projectID, + StateDrift, ps.CurrentState, fs.state, fs.billingDimensions, now); err != nil { + return corrections, err } - reconCorrections.WithLabelValues(string(StateDrift), "compute_instance").Inc() corrections++ - isBillable := events.IsBillableState(fs.state) + isBillable := isBillableForType(fs.resourceType, fs.state) ps.PreviousState = ps.CurrentState ps.CurrentState = fs.state wasBillable := ps.IsBillable @@ -222,15 +234,10 @@ func (r *Reconciler) reconcileFulfillmentResources(ctx context.Context, fulfillm } } } else if !events.DimensionsEqual(ps.BillingDimensions, fs.billingDimensions) { - ce, ceErr := buildCorrectionEvent(id, "compute_instance", fs.tenantID, fs.projectID, - BillingDimensionsDrift, ps.CurrentState, fs.state, fs.billingDimensions, nil, now) - if ceErr != nil { - return corrections, fmt.Errorf("building billing_dimensions_drift event for %s: %w", id, ceErr) - } - if err := r.publisher.Publish(ctx, ce); err != nil { - return corrections, fmt.Errorf("publishing billing_dimensions_drift for %s: %w", id, err) + if err := r.publishCorrections(ctx, id, fs.resourceType, fs.tenantID, fs.projectID, + BillingDimensionsDrift, ps.CurrentState, fs.state, fs.billingDimensions, now); err != nil { + return corrections, err } - reconCorrections.WithLabelValues(string(BillingDimensionsDrift), "compute_instance").Inc() corrections++ ps.BillingDimensions = fs.billingDimensions @@ -254,15 +261,13 @@ func (r *Reconciler) reconcileMissedDeletions(ctx context.Context, fulfillmentSt for id, ps := range projMap { if _, exists := fulfillmentState[id]; !exists { - ce, ceErr := buildCorrectionEvent(id, ps.ResourceType, ps.TenantID, ps.ProjectID, - MissedDeletion, ps.CurrentState, "", ps.BillingDimensions, nil, now) - if ceErr != nil { - return corrections, fmt.Errorf("building missed_deletion event for %s: %w", id, ceErr) + if ps.ResourceType == "cluster_order" && r.clusterClient == nil { + continue } - if err := r.publisher.Publish(ctx, ce); err != nil { - return corrections, fmt.Errorf("publishing missed_deletion for %s: %w", id, err) + if err := r.publishCorrections(ctx, id, ps.ResourceType, ps.TenantID, ps.ProjectID, + MissedDeletion, ps.CurrentState, "", ps.BillingDimensions, now); err != nil { + return corrections, err } - reconCorrections.WithLabelValues(string(MissedDeletion), ps.ResourceType).Inc() corrections++ if err := r.store.Delete(ctx, id); err != nil { @@ -287,13 +292,20 @@ func (r *Reconciler) reconcileStaleHeartbeats(ctx context.Context, now time.Time for i := range freshProjection { ps := &freshProjection[i] if ps.LastHeartbeatAt == nil || now.Sub(*ps.LastHeartbeatAt) > 2*r.heartbeatInterval { - hbEvent, hbErr := buildSyntheticHeartbeat(*ps, now) + hbEvents, hbErr := buildSyntheticHeartbeats(*ps, now) if hbErr != nil { r.logger.Error(hbErr, "building synthetic heartbeat", "resource_id", ps.ResourceID) continue } - if err := r.publisher.Publish(ctx, hbEvent); err != nil { - r.logger.Error(err, "publishing synthetic heartbeat", "resource_id", ps.ResourceID) + published := true + for _, hb := range hbEvents { + if err := r.publisher.Publish(ctx, hb); err != nil { + r.logger.Error(err, "publishing synthetic heartbeat", "resource_id", ps.ResourceID) + published = false + break + } + } + if !published { continue } heartbeatIDs = append(heartbeatIDs, ps.ResourceID) @@ -330,6 +342,7 @@ func (r *Reconciler) RunPeriodic(ctx context.Context, interval time.Duration) { } type fulfillmentResource struct { + resourceType string state string version int32 tenantID string @@ -337,10 +350,34 @@ type fulfillmentResource struct { billingDimensions map[string]any } +func isBillableForType(resourceType, state string) bool { + switch resourceType { + case "compute_instance": + return events.IsBillableState(state) + case "cluster_order": + return events.IsClusterBillableState(state) + default: + return false + } +} + func (r *Reconciler) loadFulfillmentState(ctx context.Context) (map[string]fulfillmentResource, error) { result := make(map[string]fulfillmentResource) - var offset int32 + if err := r.loadComputeInstances(ctx, result); err != nil { + return nil, err + } + if r.clusterClient != nil { + if err := r.loadClusters(ctx, result); err != nil { + return nil, err + } + } + + return result, nil +} + +func (r *Reconciler) loadComputeInstances(ctx context.Context, result map[string]fulfillmentResource) error { + var offset int32 for { limit := int32(defaultPageSize) resp, err := r.computeClient.List(ctx, &privatev1.ComputeInstancesListRequest{ @@ -348,7 +385,7 @@ func (r *Reconciler) loadFulfillmentState(ctx context.Context) (map[string]fulfi Limit: &limit, }) if err != nil { - return nil, fmt.Errorf("listing compute instances (offset=%d): %w", offset, err) + return fmt.Errorf("listing compute instances (offset=%d): %w", offset, err) } items := resp.GetItems() @@ -366,6 +403,7 @@ func (r *Reconciler) loadFulfillmentState(ctx context.Context) (map[string]fulfi version = md.GetVersion() } result[ci.GetId()] = fulfillmentResource{ + resourceType: "compute_instance", state: state, version: version, tenantID: tenantID, @@ -379,13 +417,78 @@ func (r *Reconciler) loadFulfillmentState(ctx context.Context) (map[string]fulfi } offset += int32(len(items)) } + return nil +} - return result, nil +func (r *Reconciler) loadClusters(ctx context.Context, result map[string]fulfillmentResource) error { + var offset int32 + for { + limit := int32(defaultPageSize) + resp, err := r.clusterClient.List(ctx, &privatev1.ClustersListRequest{ + Offset: &offset, + Limit: &limit, + }) + if err != nil { + return fmt.Errorf("listing clusters (offset=%d): %w", offset, err) + } + + items := resp.GetItems() + for _, cl := range items { + state := "UNSPECIFIED" + if s := cl.GetStatus(); s != nil { + state = strings.TrimPrefix(s.GetState().String(), events.ClusterStatePrefix) + } + tenantID := "" + projectID := "" + var version int32 + if md := cl.GetMetadata(); md != nil { + tenantID = md.GetTenant() + projectID = md.GetProject() + version = md.GetVersion() + } + result[cl.GetId()] = fulfillmentResource{ + resourceType: "cluster_order", + state: state, + version: version, + tenantID: tenantID, + projectID: projectID, + billingDimensions: events.ClusterBillingDimensions(cl), + } + } + + if len(items) < defaultPageSize { + break + } + offset += int32(len(items)) + } + return nil +} + +func buildSyntheticHeartbeats(ps projection.ResourceState, now time.Time) ([]cloudevents.Event, error) { + if ps.ResourceType == "cluster_order" { + components := events.DecomposeClusterComponents(ps.BillingDimensions) + if len(components) > 0 { + result := make([]cloudevents.Event, 0, len(components)) + for _, comp := range components { + ce, err := buildSingleSyntheticHeartbeat(ps, comp.FlatBillingDimensions(), events.ComponentEventID(uuid.NewString(), comp), now) + if err != nil { + return nil, err + } + result = append(result, ce) + } + return result, nil + } + } + ce, err := buildSingleSyntheticHeartbeat(ps, ps.BillingDimensions, uuid.NewString(), now) + if err != nil { + return nil, err + } + return []cloudevents.Event{ce}, nil } -func buildSyntheticHeartbeat(ps projection.ResourceState, now time.Time) (cloudevents.Event, error) { +func buildSingleSyntheticHeartbeat(ps projection.ResourceState, billingDims map[string]any, eventID string, now time.Time) (cloudevents.Event, error) { ce := cloudevents.NewEvent() - ce.SetID(uuid.NewString()) + ce.SetID(eventID) ce.SetSource("osac-metering/reconciler") ce.SetType("osac.resource.heartbeat.v1") ce.SetTime(now) @@ -403,7 +506,7 @@ func buildSyntheticHeartbeat(ps projection.ResourceState, now time.Time) (cloude "project_id": events.NilIfEmpty(ps.ProjectID), "current_state": ps.CurrentState, "duration_seconds": durationSeconds, - "billing_dimensions": ps.BillingDimensions, + "billing_dimensions": billingDims, "schema_version": "v1", } if err := ce.SetData(cloudevents.ApplicationJSON, data); err != nil { diff --git a/osac-metering/metering-service/internal/reconciliation/reconciler_test.go b/osac-metering/metering-service/internal/reconciliation/reconciler_test.go index daccc67cb..b5b7ed952 100644 --- a/osac-metering/metering-service/internal/reconciliation/reconciler_test.go +++ b/osac-metering/metering-service/internal/reconciliation/reconciler_test.go @@ -159,7 +159,7 @@ var _ = Describe("Reconciler", func() { } store := newMockStore() pub := &mockPublisher{} - recon := reconciliation.NewReconciler(client, store, pub, logr.Discard(), 60*time.Second) + recon := reconciliation.NewReconciler(client, nil, store, pub, logr.Discard(), 60*time.Second) Expect(recon.Reconcile(ctx)).To(Succeed()) @@ -192,7 +192,7 @@ var _ = Describe("Reconciler", func() { CurrentState: "RUNNING", } pub := &mockPublisher{} - recon := reconciliation.NewReconciler(client, store, pub, logr.Discard(), 60*time.Second) + recon := reconciliation.NewReconciler(client, nil, store, pub, logr.Discard(), 60*time.Second) Expect(recon.Reconcile(ctx)).To(Succeed()) @@ -223,7 +223,7 @@ var _ = Describe("Reconciler", func() { FulfillmentVersion: 3, } pub := &mockPublisher{} - recon := reconciliation.NewReconciler(client, store, pub, logr.Discard(), 60*time.Second) + recon := reconciliation.NewReconciler(client, nil, store, pub, logr.Discard(), 60*time.Second) Expect(recon.Reconcile(ctx)).To(Succeed()) @@ -253,7 +253,7 @@ var _ = Describe("Reconciler", func() { FulfillmentVersion: 1, } pub := &mockPublisher{} - recon := reconciliation.NewReconciler(client, store, pub, logr.Discard(), 60*time.Second) + recon := reconciliation.NewReconciler(client, nil, store, pub, logr.Discard(), 60*time.Second) Expect(recon.Reconcile(ctx)).To(Succeed()) @@ -283,7 +283,7 @@ var _ = Describe("Reconciler", func() { LastHeartbeatAt: &staleTime, } pub := &mockPublisher{} - recon := reconciliation.NewReconciler(client, store, pub, logr.Discard(), 60*time.Second) + recon := reconciliation.NewReconciler(client, nil, store, pub, logr.Discard(), 60*time.Second) Expect(recon.Reconcile(ctx)).To(Succeed()) @@ -307,7 +307,7 @@ var _ = Describe("Reconciler", func() { } store := newMockStore() pub := &mockPublisher{} - recon := reconciliation.NewReconciler(client, store, pub, logr.Discard(), 60*time.Second) + recon := reconciliation.NewReconciler(client, nil, store, pub, logr.Discard(), 60*time.Second) Expect(recon.Reconcile(ctx)).To(Succeed()) @@ -339,7 +339,7 @@ var _ = Describe("Reconciler", func() { }, } pub := &mockPublisher{} - recon := reconciliation.NewReconciler(client, store, pub, logr.Discard(), 60*time.Second) + recon := reconciliation.NewReconciler(client, nil, store, pub, logr.Discard(), 60*time.Second) Expect(recon.Reconcile(ctx)).To(Succeed()) @@ -352,7 +352,7 @@ var _ = Describe("Reconciler", func() { client := &mockComputeClient{err: fmt.Errorf("connection refused")} store := newMockStore() pub := &mockPublisher{} - recon := reconciliation.NewReconciler(client, store, pub, logr.Discard(), 60*time.Second) + recon := reconciliation.NewReconciler(client, nil, store, pub, logr.Discard(), 60*time.Second) err := recon.Reconcile(ctx) Expect(err).To(HaveOccurred()) @@ -367,7 +367,7 @@ var _ = Describe("Reconciler", func() { } store := newMockStore() pub := &mockPublisher{err: fmt.Errorf("kafka down")} - recon := reconciliation.NewReconciler(client, store, pub, logr.Discard(), 60*time.Second) + recon := reconciliation.NewReconciler(client, nil, store, pub, logr.Discard(), 60*time.Second) err := recon.Reconcile(ctx) Expect(err).To(HaveOccurred()) @@ -408,7 +408,7 @@ var _ = Describe("Reconciler", func() { BillingDimensions: map[string]any{"instance_type": "m5.large"}, } pub := &mockPublisher{} - recon := reconciliation.NewReconciler(client, store, pub, logr.Discard(), 60*time.Second) + recon := reconciliation.NewReconciler(client, nil, store, pub, logr.Discard(), 60*time.Second) Expect(recon.Reconcile(ctx)).To(Succeed()) @@ -463,7 +463,7 @@ var _ = Describe("Reconciler", func() { BillingDimensions: map[string]any{"instance_type": "m5.large"}, } pub := &mockPublisher{} - recon := reconciliation.NewReconciler(client, store, pub, logr.Discard(), 60*time.Second) + recon := reconciliation.NewReconciler(client, nil, store, pub, logr.Discard(), 60*time.Second) Expect(recon.Reconcile(ctx)).To(Succeed()) @@ -495,7 +495,7 @@ var _ = Describe("Reconciler", func() { }, } pub := &mockPublisher{} - recon := reconciliation.NewReconciler(client, store, pub, logr.Discard(), 60*time.Second) + recon := reconciliation.NewReconciler(client, nil, store, pub, logr.Discard(), 60*time.Second) Expect(recon.Reconcile(ctx)).To(Succeed()) @@ -542,7 +542,7 @@ var _ = Describe("Reconciler", func() { }, } pub := &mockPublisher{} - recon := reconciliation.NewReconciler(client, store, pub, logr.Discard(), 60*time.Second) + recon := reconciliation.NewReconciler(client, nil, store, pub, logr.Discard(), 60*time.Second) Expect(recon.Reconcile(ctx)).To(Succeed()) @@ -575,7 +575,7 @@ var _ = Describe("Reconciler", func() { "vm-stale": projection.ErrStaleVersion, } pub := &mockPublisher{} - recon := reconciliation.NewReconciler(client, store, pub, logr.Discard(), 60*time.Second) + recon := reconciliation.NewReconciler(client, nil, store, pub, logr.Discard(), 60*time.Second) Expect(recon.Reconcile(ctx)).To(Succeed()) @@ -592,7 +592,7 @@ var _ = Describe("Reconciler", func() { } store := newMockStore() pub := &mockPublisher{} - recon := reconciliation.NewReconciler(client, store, pub, logr.Discard(), 60*time.Second) + recon := reconciliation.NewReconciler(client, nil, store, pub, logr.Discard(), 60*time.Second) Expect(recon.Reconcile(ctx)).To(Succeed()) @@ -615,7 +615,7 @@ var _ = Describe("Reconciler", func() { client := &mockComputeClient{} store := newMockStore() pub := &mockPublisher{} - recon := reconciliation.NewReconciler(client, store, pub, logr.Discard(), 60*time.Second) + recon := reconciliation.NewReconciler(client, nil, store, pub, logr.Discard(), 60*time.Second) periodicCtx, periodicCancel := context.WithCancel(ctx) done := make(chan struct{}) From 8202dde9474ce82142eae85a10698dd63930451f Mon Sep 17 00:00:00 2001 From: omer-vishlitzky Date: Thu, 6 Aug 2026 09:13:52 +0300 Subject: [PATCH 04/18] OSAC-3421: Add CaaS unit tests for consumer, heartbeat, and reconciler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consumer tests (8 new specs): - Cluster CREATED publishes exactly 1 event (not N+1) - Cluster started.v1 publishes N+1 events (3 for 1 CP + 2 workers) - Each decomposed event has distinct per-component billing_dimensions - Each decomposed event has deterministic component-scoped ID - PROGRESSING→READY skips publish but updates projection - READY→FAILED publishes N+1 suspended.v1 - Scaling publishes updated.v1 only for changed component - Cluster DELETED publishes exactly 1 event Heartbeat tests (4 new specs): - cluster_order produces N+1 heartbeats with flat billing_dimensions - VMaaS and CaaS in same tick produce correct event counts - Cluster ID checkpointed after all component heartbeats - Partial N+1 publish failure does not checkpoint Reconciler tests (5 new specs): - Cluster missed_creation emits N+1 correction events with flat dims - Cluster state_drift emits N+1 correction events - Cluster missed_deletion emits N+1 correction events - Nil clusterClient skips cluster missed_deletion - ListClusters pagination with 600 clusters Assisted-by: Claude Code Signed-off-by: omer-vishlitzky --- .../internal/heartbeat/generator_test.go | 123 ++++++ .../reconciliation/reconciler_test.go | 234 ++++++++++++ .../internal/watch/consumer_test.go | 353 ++++++++++++++++++ 3 files changed, 710 insertions(+) diff --git a/osac-metering/metering-service/internal/heartbeat/generator_test.go b/osac-metering/metering-service/internal/heartbeat/generator_test.go index e143fb7e7..436e98af6 100644 --- a/osac-metering/metering-service/internal/heartbeat/generator_test.go +++ b/osac-metering/metering-service/internal/heartbeat/generator_test.go @@ -255,4 +255,127 @@ var _ = Describe("Generator", func() { Expect(pub.published[0].Extensions()["osacresourceid"]).To(Equal("vm-1")) }) }) + + Describe("CaaS cluster N+1 heartbeats", func() { + makeClusterBillableState := func(id string) projection.ResourceState { + now := time.Now().UTC().Truncate(time.Microsecond) + return projection.ResourceState{ + ResourceID: id, + ResourceType: "cluster_order", + TenantID: "tenant-1", + ProjectID: "project-1", + CurrentState: "READY", + IsBillable: true, + BillableSince: &now, + BillingDimensions: map[string]any{ + "cluster_template": "ocp-ci-small", + "release_image": "quay.io/ocp:4.17.0", + "components": []any{ + map[string]any{"component": "control_plane", "host_type": "_control_plane", "node_count": float64(1)}, + map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": float64(2)}, + }, + }, + } + } + + It("publishes N+1 heartbeats per cluster (1 CP + 1 worker = 2)", func() { + store := &mockStore{ + billable: []projection.ResourceState{makeClusterBillableState("cl-1")}, + } + pub := &mockPublisher{} + gen := heartbeat.NewGenerator(store, pub, logr.Discard(), 100*time.Millisecond) + + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + + err := gen.Run(ctx) + Expect(err).ToNot(HaveOccurred()) + + pub.mu.Lock() + defer pub.mu.Unlock() + Expect(len(pub.published)).To(BeNumerically(">=", 2)) + + components := map[string]bool{} + for _, e := range pub.published { + Expect(e.Type()).To(Equal("osac.resource.heartbeat.v1")) + var data map[string]any + Expect(json.Unmarshal(e.Data(), &data)).To(Succeed()) + bd := data["billing_dimensions"].(map[string]any) + Expect(bd).NotTo(HaveKey("components")) + Expect(bd).To(HaveKey("component")) + Expect(bd).To(HaveKey("host_type")) + Expect(bd).To(HaveKey("node_count")) + comp := bd["component"].(string) + ":" + bd["host_type"].(string) + components[comp] = true + } + Expect(components).To(HaveKey("control_plane:_control_plane")) + Expect(components).To(HaveKey("worker:gpu-h100")) + }) + + It("VMaaS and CaaS in same tick produce correct event counts", func() { + store := &mockStore{ + billable: []projection.ResourceState{ + makeBillableState("vm-1"), + makeClusterBillableState("cl-1"), + }, + } + pub := &mockPublisher{} + gen := heartbeat.NewGenerator(store, pub, logr.Discard(), 100*time.Millisecond) + + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + + err := gen.Run(ctx) + Expect(err).ToNot(HaveOccurred()) + + pub.mu.Lock() + defer pub.mu.Unlock() + // Per tick: 1 VMaaS + 2 CaaS (CP + 1 worker) = 3 + Expect(len(pub.published)).To(BeNumerically(">=", 3)) + + store.mu.Lock() + defer store.mu.Unlock() + Expect(store.updatedIDs).To(ContainElement("vm-1")) + Expect(store.updatedIDs).To(ContainElement("cl-1")) + }) + + It("checkpoints cluster ID after all N+1 component heartbeats published", func() { + store := &mockStore{ + billable: []projection.ResourceState{makeClusterBillableState("cl-cp")}, + } + pub := &mockPublisher{} + gen := heartbeat.NewGenerator(store, pub, logr.Discard(), 100*time.Millisecond) + + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + + err := gen.Run(ctx) + Expect(err).ToNot(HaveOccurred()) + + store.mu.Lock() + defer store.mu.Unlock() + Expect(store.updatedIDs).To(ContainElement("cl-cp")) + }) + + It("fails tick on partial N+1 heartbeat publish failure", func() { + store := &mockStore{ + billable: []projection.ResourceState{makeClusterBillableState("cl-fail")}, + } + pub := &mockPublisher{ + err: fmt.Errorf("kafka unavailable"), + failAfter: 1, + } + gen := heartbeat.NewGenerator(store, pub, logr.Discard(), 100*time.Millisecond) + + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + + err := gen.Run(ctx) + Expect(err).ToNot(HaveOccurred()) + + store.mu.Lock() + defer store.mu.Unlock() + Expect(store.updatedIDs).To(BeEmpty()) + }) + }) }) diff --git a/osac-metering/metering-service/internal/reconciliation/reconciler_test.go b/osac-metering/metering-service/internal/reconciliation/reconciler_test.go index b5b7ed952..f280abcb7 100644 --- a/osac-metering/metering-service/internal/reconciliation/reconciler_test.go +++ b/osac-metering/metering-service/internal/reconciliation/reconciler_test.go @@ -41,6 +41,29 @@ func (m *mockComputeClient) List(_ context.Context, req *privatev1.ComputeInstan }, nil } +type mockClusterClient struct { + items []*privatev1.Cluster + err error +} + +func (m *mockClusterClient) List(_ context.Context, req *privatev1.ClustersListRequest, _ ...grpc.CallOption) (*privatev1.ClustersListResponse, error) { + if m.err != nil { + return nil, m.err + } + offset := int(req.GetOffset()) + limit := int(req.GetLimit()) + if offset >= len(m.items) { + return &privatev1.ClustersListResponse{}, nil + } + end := offset + limit + if end > len(m.items) { + end = len(m.items) + } + return &privatev1.ClustersListResponse{ + Items: m.items[offset:end], + }, nil +} + type mockStore struct { mu sync.Mutex states map[string]projection.ResourceState @@ -629,4 +652,215 @@ var _ = Describe("Reconciler", func() { Eventually(done, time.Second).Should(BeClosed()) }) }) + + Describe("CaaS cluster reconciliation", func() { + makeClusterProto := func(id, tenant string, state privatev1.ClusterState, version int32) *privatev1.Cluster { + releaseImage := "quay.io/ocp:4.17.0" + return &privatev1.Cluster{ + Id: id, + Metadata: &privatev1.Metadata{ + Tenant: tenant, + Version: version, + }, + Spec: &privatev1.ClusterSpec{ + Template: "ocp-ci-small", + ReleaseImage: &releaseImage, + NodeSets: map[string]*privatev1.ClusterNodeSet{ + "gpu-workers": {HostType: "gpu-h100", Size: 2}, + }, + }, + Status: &privatev1.ClusterStatus{State: state}, + } + } + + It("detects missed_creation for cluster and emits N+1 correction events", func() { + computeClient := &mockComputeClient{} + clusterClient := &mockClusterClient{ + items: []*privatev1.Cluster{ + makeClusterProto("cl-missed", "tenant-1", privatev1.ClusterState_CLUSTER_STATE_READY, 1), + }, + } + store := newMockStore() + pub := &mockPublisher{} + recon := reconciliation.NewReconciler(computeClient, clusterClient, store, pub, logr.Discard(), 60*time.Second) + + Expect(recon.Reconcile(ctx)).To(Succeed()) + + pub.mu.Lock() + defer pub.mu.Unlock() + correctionCount := 0 + for _, e := range pub.published { + if e.Type() == "osac.resource.correction.v1" { + correctionCount++ + var data map[string]any + Expect(json.Unmarshal(e.Data(), &data)).To(Succeed()) + Expect(data["reason"]).To(Equal("missed_creation")) + Expect(data["resource_type"]).To(Equal("cluster_order")) + bd := data["billing_dimensions"].(map[string]any) + Expect(bd).To(HaveKey("component")) + Expect(bd).To(HaveKey("host_type")) + Expect(bd).NotTo(HaveKey("components")) + } + } + // 1 control_plane + 1 gpu-h100 worker = 2 correction events + Expect(correctionCount).To(Equal(2)) + + store.mu.Lock() + defer store.mu.Unlock() + Expect(store.states).To(HaveKey("cl-missed")) + Expect(store.states["cl-missed"].ResourceType).To(Equal("cluster_order")) + Expect(store.states["cl-missed"].IsBillable).To(BeTrue()) + }) + + It("detects state_drift for cluster and emits N+1 correction events", func() { + computeClient := &mockComputeClient{} + clusterClient := &mockClusterClient{ + items: []*privatev1.Cluster{ + makeClusterProto("cl-drift", "tenant-1", privatev1.ClusterState_CLUSTER_STATE_FAILED, 2), + }, + } + store := newMockStore() + now := time.Now().UTC().Truncate(time.Microsecond) + store.states["cl-drift"] = projection.ResourceState{ + ResourceID: "cl-drift", + ResourceType: "cluster_order", + TenantID: "tenant-1", + CurrentState: "READY", + IsBillable: true, + BillableSince: &now, + FulfillmentVersion: 1, + BillingDimensions: map[string]any{ + "cluster_template": "ocp-ci-small", + "release_image": "quay.io/ocp:4.17.0", + "components": []any{ + map[string]any{"component": "control_plane", "host_type": "_control_plane", "node_count": float64(1)}, + map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": float64(2)}, + }, + }, + } + + pub := &mockPublisher{} + recon := reconciliation.NewReconciler(computeClient, clusterClient, store, pub, logr.Discard(), 60*time.Second) + + Expect(recon.Reconcile(ctx)).To(Succeed()) + + pub.mu.Lock() + defer pub.mu.Unlock() + driftCount := 0 + for _, e := range pub.published { + if e.Type() == "osac.resource.correction.v1" { + var data map[string]any + Expect(json.Unmarshal(e.Data(), &data)).To(Succeed()) + if data["reason"] == "state_drift" { + driftCount++ + } + } + } + Expect(driftCount).To(Equal(2)) + + store.mu.Lock() + defer store.mu.Unlock() + Expect(store.states["cl-drift"].CurrentState).To(Equal("FAILED")) + Expect(store.states["cl-drift"].IsBillable).To(BeFalse()) + }) + + It("detects missed_deletion for cluster and emits N+1 correction events", func() { + computeClient := &mockComputeClient{} + clusterClient := &mockClusterClient{} + store := newMockStore() + now := time.Now().UTC().Truncate(time.Microsecond) + store.states["cl-gone"] = projection.ResourceState{ + ResourceID: "cl-gone", + ResourceType: "cluster_order", + TenantID: "tenant-1", + CurrentState: "READY", + IsBillable: true, + BillableSince: &now, + BillingDimensions: map[string]any{ + "cluster_template": "ocp-ci-small", + "components": []any{ + map[string]any{"component": "control_plane", "host_type": "_control_plane", "node_count": float64(1)}, + map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": float64(2)}, + }, + }, + } + + pub := &mockPublisher{} + recon := reconciliation.NewReconciler(computeClient, clusterClient, store, pub, logr.Discard(), 60*time.Second) + + Expect(recon.Reconcile(ctx)).To(Succeed()) + + pub.mu.Lock() + defer pub.mu.Unlock() + deletionCount := 0 + for _, e := range pub.published { + if e.Type() == "osac.resource.correction.v1" { + var data map[string]any + Expect(json.Unmarshal(e.Data(), &data)).To(Succeed()) + if data["reason"] == "missed_deletion" { + deletionCount++ + bd := data["billing_dimensions"].(map[string]any) + Expect(bd).To(HaveKey("component")) + Expect(bd).NotTo(HaveKey("components")) + } + } + } + Expect(deletionCount).To(Equal(2)) + + store.mu.Lock() + defer store.mu.Unlock() + Expect(store.states).ToNot(HaveKey("cl-gone")) + }) + + It("skips cluster missed_deletion when clusterClient is nil", func() { + computeClient := &mockComputeClient{} + store := newMockStore() + now := time.Now().UTC().Truncate(time.Microsecond) + store.states["cl-safe"] = projection.ResourceState{ + ResourceID: "cl-safe", + ResourceType: "cluster_order", + TenantID: "tenant-1", + CurrentState: "READY", + IsBillable: true, + BillableSince: &now, + LastHeartbeatAt: &now, + BillingDimensions: map[string]any{}, + } + + pub := &mockPublisher{} + recon := reconciliation.NewReconciler(computeClient, nil, store, pub, logr.Discard(), 60*time.Second) + + Expect(recon.Reconcile(ctx)).To(Succeed()) + + pub.mu.Lock() + defer pub.mu.Unlock() + for _, e := range pub.published { + Expect(e.Type()).ToNot(Equal("osac.resource.correction.v1")) + } + + store.mu.Lock() + defer store.mu.Unlock() + Expect(store.states).To(HaveKey("cl-safe")) + }) + + It("paginates ListClusters correctly", func() { + clusters := make([]*privatev1.Cluster, 0, 600) + for i := range 600 { + clusters = append(clusters, makeClusterProto( + fmt.Sprintf("cl-%d", i), "tenant-1", + privatev1.ClusterState_CLUSTER_STATE_READY, 1)) + } + computeClient := &mockComputeClient{} + clusterClient := &mockClusterClient{items: clusters} + store := newMockStore() + pub := &mockPublisher{} + recon := reconciliation.NewReconciler(computeClient, clusterClient, store, pub, logr.Discard(), 60*time.Second) + + Expect(recon.Reconcile(ctx)).To(Succeed()) + + store.mu.Lock() + defer store.mu.Unlock() + Expect(store.states).To(HaveLen(600)) + }) + }) }) diff --git a/osac-metering/metering-service/internal/watch/consumer_test.go b/osac-metering/metering-service/internal/watch/consumer_test.go index a436611f6..d18cd8a3e 100644 --- a/osac-metering/metering-service/internal/watch/consumer_test.go +++ b/osac-metering/metering-service/internal/watch/consumer_test.go @@ -807,4 +807,357 @@ var _ = Describe("Consumer", func() { Expect(store.states["vm-new"].BillableSince).ToNot(BeNil()) }) }) + + Describe("CaaS Cluster events", func() { + makeCluster := func(id, tenant string, state privatev1.ClusterState, nodeSets map[string]*privatev1.ClusterNodeSet) *privatev1.Cluster { + releaseImage := "quay.io/ocp:4.17.0" + return &privatev1.Cluster{ + Id: id, + Metadata: &privatev1.Metadata{ + Tenant: tenant, + Version: 2, + CreationTimestamp: timestamppb.Now(), + }, + Spec: &privatev1.ClusterSpec{ + Template: "ocp-ci-small", + ReleaseImage: &releaseImage, + NodeSets: nodeSets, + }, + Status: &privatev1.ClusterStatus{ + State: state, + StateTransitionTime: timestamppb.Now(), + }, + } + } + + defaultNodeSets := func() map[string]*privatev1.ClusterNodeSet { + return map[string]*privatev1.ClusterNodeSet{ + "gpu-workers": {HostType: "gpu-h100", Size: 2}, + "cpu-workers": {HostType: "cpu-only", Size: 3}, + } + } + + clusterBillingDims := func() map[string]any { + return map[string]any{ + "cluster_template": "ocp-ci-small", + "release_image": "quay.io/ocp:4.17.0", + "components": []any{ + map[string]any{"component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + map[string]any{"component": "worker", "host_type": "cpu-only", "node_count": int32(3)}, + map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, + }, + } + } + + It("publishes exactly 1 event for cluster CREATED (not N+1)", func() { + cl := makeCluster("cl-1", "tenant-1", privatev1.ClusterState_CLUSTER_STATE_PROGRESSING, defaultNodeSets()) + event := &privatev1.Event{ + Id: "evt-create", + Type: privatev1.EventType_EVENT_TYPE_OBJECT_CREATED, + Payload: &privatev1.Event_Cluster{Cluster: cl}, + } + + stream := &mockWatchStream{ + responses: []*privatev1.EventsWatchResponse{makeResponse(event)}, + } + client.results = []mockStreamResult{{stream: stream}} + + pub := &mockPublisher{published: make([]cloudevents.Event, 0, 1), cancelFunc: cancel} + consumer := newConsumer(pub) + + err := consumer.Run(ctx) + Expect(err).ToNot(HaveOccurred()) + + pub.mu.Lock() + defer pub.mu.Unlock() + Expect(pub.published).To(HaveLen(1)) + Expect(pub.published[0].Type()).To(Equal("osac.resource.created.v1")) + Expect(pub.published[0].Extensions()["osacresourcetype"]).To(Equal("cluster_order")) + }) + + It("publishes N+1 started.v1 events for new cluster PROGRESSING", func() { + cl := makeCluster("cl-start", "tenant-1", privatev1.ClusterState_CLUSTER_STATE_PROGRESSING, defaultNodeSets()) + event := &privatev1.Event{ + Id: "evt-start", + Type: privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, + Payload: &privatev1.Event_Cluster{Cluster: cl}, + } + + stream := &mockWatchStream{ + responses: []*privatev1.EventsWatchResponse{makeResponse(event)}, + } + client.results = []mockStreamResult{{stream: stream}} + + // 3 events: 1 control_plane + 2 workers + pub := &mockPublisher{published: make([]cloudevents.Event, 0, 3), cancelFunc: cancel} + consumer := newConsumer(pub) + + err := consumer.Run(ctx) + Expect(err).ToNot(HaveOccurred()) + + pub.mu.Lock() + defer pub.mu.Unlock() + Expect(pub.published).To(HaveLen(3)) + for _, e := range pub.published { + Expect(e.Type()).To(Equal("osac.resource.started.v1")) + } + }) + + It("each decomposed event has distinct per-component billing_dimensions", func() { + cl := makeCluster("cl-decomp", "tenant-1", privatev1.ClusterState_CLUSTER_STATE_PROGRESSING, defaultNodeSets()) + event := &privatev1.Event{ + Id: "evt-decomp", + Type: privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, + Payload: &privatev1.Event_Cluster{Cluster: cl}, + } + + stream := &mockWatchStream{ + responses: []*privatev1.EventsWatchResponse{makeResponse(event)}, + } + client.results = []mockStreamResult{{stream: stream}} + + pub := &mockPublisher{published: make([]cloudevents.Event, 0, 3), cancelFunc: cancel} + consumer := newConsumer(pub) + + err := consumer.Run(ctx) + Expect(err).ToNot(HaveOccurred()) + + pub.mu.Lock() + defer pub.mu.Unlock() + Expect(pub.published).To(HaveLen(3)) + + components := map[string]bool{} + for _, e := range pub.published { + var data map[string]any + Expect(json.Unmarshal(e.Data(), &data)).To(Succeed()) + bd := data["billing_dimensions"].(map[string]any) + comp := bd["component"].(string) + ":" + bd["host_type"].(string) + components[comp] = true + Expect(bd).To(HaveKey("cluster_template")) + Expect(bd).To(HaveKey("node_count")) + Expect(bd).NotTo(HaveKey("components")) + } + Expect(components).To(HaveLen(3)) + Expect(components).To(HaveKey("control_plane:_control_plane")) + Expect(components).To(HaveKey("worker:cpu-only")) + Expect(components).To(HaveKey("worker:gpu-h100")) + }) + + It("each decomposed event has deterministic component-scoped ID", func() { + cl := makeCluster("cl-ids", "tenant-1", privatev1.ClusterState_CLUSTER_STATE_PROGRESSING, defaultNodeSets()) + event := &privatev1.Event{ + Id: "evt-ids", + Type: privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, + Payload: &privatev1.Event_Cluster{Cluster: cl}, + } + + stream := &mockWatchStream{ + responses: []*privatev1.EventsWatchResponse{makeResponse(event)}, + } + client.results = []mockStreamResult{{stream: stream}} + + pub := &mockPublisher{published: make([]cloudevents.Event, 0, 3), cancelFunc: cancel} + consumer := newConsumer(pub) + + err := consumer.Run(ctx) + Expect(err).ToNot(HaveOccurred()) + + pub.mu.Lock() + defer pub.mu.Unlock() + ids := map[string]bool{} + for _, e := range pub.published { + Expect(e.ID()).To(ContainSubstring("evt-ids/")) + ids[e.ID()] = true + } + Expect(ids).To(HaveLen(3)) + }) + + It("skips publish on PROGRESSING→READY (both billable) but updates projection", func() { + store := newMockStore() + now := time.Now().UTC().Truncate(time.Microsecond) + store.states["cl-ready"] = projection.ResourceState{ + ResourceID: "cl-ready", + ResourceType: "cluster_order", + TenantID: "tenant-1", + CurrentState: "PROGRESSING", + IsBillable: true, + BillableSince: &now, + FulfillmentVersion: 1, + BillingDimensions: clusterBillingDims(), + TransitionTime: now, + } + + cl := makeCluster("cl-ready", "tenant-1", privatev1.ClusterState_CLUSTER_STATE_READY, defaultNodeSets()) + clEvent := &privatev1.Event{ + Id: "evt-ready", + Type: privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, + Payload: &privatev1.Event_Cluster{Cluster: cl}, + } + + stream := &mockWatchStream{ + responses: []*privatev1.EventsWatchResponse{makeResponse(clEvent)}, + } + client.results = []mockStreamResult{{stream: stream}} + + pub := &mockPublisher{} + consumer := newConsumerWithStore(pub, store) + + done := make(chan error, 1) + go func() { done <- consumer.Run(ctx) }() + + time.Sleep(50 * time.Millisecond) + cancel() + Eventually(done, time.Second).Should(Receive(BeNil())) + + pub.mu.Lock() + defer pub.mu.Unlock() + Expect(pub.published).To(BeEmpty()) + + store.mu.Lock() + defer store.mu.Unlock() + Expect(store.states["cl-ready"].CurrentState).To(Equal("READY")) + Expect(store.states["cl-ready"].IsBillable).To(BeTrue()) + Expect(store.states["cl-ready"].BillableSince).To(Equal(&now)) + }) + + It("publishes N+1 suspended.v1 on READY→FAILED", func() { + store := newMockStore() + now := time.Now().UTC().Truncate(time.Microsecond) + store.states["cl-fail"] = projection.ResourceState{ + ResourceID: "cl-fail", + ResourceType: "cluster_order", + TenantID: "tenant-1", + CurrentState: "READY", + IsBillable: true, + BillableSince: &now, + FulfillmentVersion: 1, + BillingDimensions: clusterBillingDims(), + TransitionTime: now, + } + + cl := makeCluster("cl-fail", "tenant-1", privatev1.ClusterState_CLUSTER_STATE_FAILED, defaultNodeSets()) + event := &privatev1.Event{ + Id: "evt-fail", + Type: privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, + Payload: &privatev1.Event_Cluster{Cluster: cl}, + } + + stream := &mockWatchStream{ + responses: []*privatev1.EventsWatchResponse{makeResponse(event)}, + } + client.results = []mockStreamResult{{stream: stream}} + + pub := &mockPublisher{published: make([]cloudevents.Event, 0, 3), cancelFunc: cancel} + consumer := newConsumerWithStore(pub, store) + + err := consumer.Run(ctx) + Expect(err).ToNot(HaveOccurred()) + + pub.mu.Lock() + defer pub.mu.Unlock() + Expect(pub.published).To(HaveLen(3)) + for _, e := range pub.published { + Expect(e.Type()).To(Equal("osac.resource.suspended.v1")) + } + + store.mu.Lock() + defer store.mu.Unlock() + Expect(store.states["cl-fail"].IsBillable).To(BeFalse()) + Expect(store.states["cl-fail"].BillableSince).To(BeNil()) + }) + + It("publishes updated.v1 only for changed component on scaling", func() { + store := newMockStore() + now := time.Now().Add(-1 * time.Hour).UTC().Truncate(time.Microsecond) + store.states["cl-scale"] = projection.ResourceState{ + ResourceID: "cl-scale", + ResourceType: "cluster_order", + TenantID: "tenant-1", + CurrentState: "READY", + IsBillable: true, + BillableSince: &now, + FulfillmentVersion: 1, + BillingDimensions: clusterBillingDims(), + TransitionTime: now, + } + + // Scale gpu-h100 from 2 to 4, cpu-only stays at 3 + scaledNodeSets := map[string]*privatev1.ClusterNodeSet{ + "gpu-workers": {HostType: "gpu-h100", Size: 4}, + "cpu-workers": {HostType: "cpu-only", Size: 3}, + } + cl := makeCluster("cl-scale", "tenant-1", privatev1.ClusterState_CLUSTER_STATE_READY, scaledNodeSets) + event := &privatev1.Event{ + Id: "evt-scale", + Type: privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, + Payload: &privatev1.Event_Cluster{Cluster: cl}, + } + + stream := &mockWatchStream{ + responses: []*privatev1.EventsWatchResponse{makeResponse(event)}, + } + client.results = []mockStreamResult{{stream: stream}} + + pub := &mockPublisher{published: make([]cloudevents.Event, 0, 1), cancelFunc: cancel} + consumer := newConsumerWithStore(pub, store) + + err := consumer.Run(ctx) + Expect(err).ToNot(HaveOccurred()) + + pub.mu.Lock() + defer pub.mu.Unlock() + Expect(pub.published).To(HaveLen(1)) + Expect(pub.published[0].Type()).To(Equal("osac.resource.updated.v1")) + + var data map[string]any + Expect(json.Unmarshal(pub.published[0].Data(), &data)).To(Succeed()) + bd := data["billing_dimensions"].(map[string]any) + Expect(bd["host_type"]).To(Equal("gpu-h100")) + Expect(bd["node_count"]).To(BeNumerically("==", 4)) + Expect(data["duration_seconds"]).ToNot(BeNil()) + }) + + It("publishes exactly 1 event for cluster DELETED (not N+1)", func() { + store := newMockStore() + now := time.Now().UTC().Truncate(time.Microsecond) + store.states["cl-del"] = projection.ResourceState{ + ResourceID: "cl-del", + ResourceType: "cluster_order", + TenantID: "tenant-1", + CurrentState: "DELETING", + IsBillable: false, + FulfillmentVersion: 1, + BillingDimensions: clusterBillingDims(), + TransitionTime: now, + } + + cl := makeCluster("cl-del", "tenant-1", privatev1.ClusterState_CLUSTER_STATE_DELETING, defaultNodeSets()) + cl.Metadata.DeletionTimestamp = timestamppb.Now() + event := &privatev1.Event{ + Id: "evt-del", + Type: privatev1.EventType_EVENT_TYPE_OBJECT_DELETED, + Payload: &privatev1.Event_Cluster{Cluster: cl}, + } + + stream := &mockWatchStream{ + responses: []*privatev1.EventsWatchResponse{makeResponse(event)}, + } + client.results = []mockStreamResult{{stream: stream}} + + pub := &mockPublisher{published: make([]cloudevents.Event, 0, 1), cancelFunc: cancel} + consumer := newConsumerWithStore(pub, store) + + err := consumer.Run(ctx) + Expect(err).ToNot(HaveOccurred()) + + pub.mu.Lock() + defer pub.mu.Unlock() + Expect(pub.published).To(HaveLen(1)) + Expect(pub.published[0].Type()).To(Equal("osac.resource.deleted.v1")) + + store.mu.Lock() + defer store.mu.Unlock() + Expect(store.states).ToNot(HaveKey("cl-del")) + }) + }) }) From 0bc50a45b22503150bd552e643b4315c6bb8691b Mon Sep 17 00:00:00 2001 From: omer-vishlitzky Date: Thu, 6 Aug 2026 09:18:22 +0300 Subject: [PATCH 05/18] OSAC-3410: Fix go.work.sum, deduplicate heartbeat builder, add nil-client log - Sync go.work.sum (pre-commit fix) - Consolidate buildComponentHeartbeat into buildHeartbeatEvent with eventID and dims params (CodeRabbit feedback) - Log once per reconciliation run when clusterClient is nil Assisted-by: Claude Code Signed-off-by: omer-vishlitzky --- .../internal/heartbeat/generator.go | 38 +++---------------- .../internal/reconciliation/reconciler.go | 5 +++ 2 files changed, 11 insertions(+), 32 deletions(-) diff --git a/osac-metering/metering-service/internal/heartbeat/generator.go b/osac-metering/metering-service/internal/heartbeat/generator.go index 33d144db3..d4a1e8103 100644 --- a/osac-metering/metering-service/internal/heartbeat/generator.go +++ b/osac-metering/metering-service/internal/heartbeat/generator.go @@ -126,7 +126,7 @@ func (g *Generator) tick(ctx context.Context) error { func (g *Generator) buildHeartbeatEvents(state *projection.ResourceState, now time.Time) ([]cloudevents.Event, error) { if state.ResourceType != "cluster_order" { - ce, err := g.buildHeartbeatEvent(state, now) + ce, err := g.buildHeartbeatEvent(state, uuid.NewString(), state.BillingDimensions, now) if err != nil { return nil, err } @@ -135,7 +135,7 @@ func (g *Generator) buildHeartbeatEvents(state *projection.ResourceState, now ti components := events.DecomposeClusterComponents(state.BillingDimensions) if len(components) == 0 { - ce, err := g.buildHeartbeatEvent(state, now) + ce, err := g.buildHeartbeatEvent(state, uuid.NewString(), state.BillingDimensions, now) if err != nil { return nil, err } @@ -154,38 +154,12 @@ func (g *Generator) buildHeartbeatEvents(state *projection.ResourceState, now ti } func (g *Generator) buildComponentHeartbeat(state *projection.ResourceState, comp events.ComponentRecord, now time.Time) (cloudevents.Event, error) { - ce := cloudevents.NewEvent() - ce.SetID(events.ComponentEventID(uuid.NewString(), comp)) - ce.SetSource("osac-metering") - ce.SetType("osac.resource.heartbeat.v1") - ce.SetTime(now) - - events.SetOSACExtensions(&ce, state.ResourceID, state.ResourceType, state.TenantID, state.ProjectID) - - var durationSeconds float64 - if state.BillableSince != nil { - durationSeconds = now.Sub(*state.BillableSince).Seconds() - } - - data := heartbeatData{ - ResourceID: state.ResourceID, - ResourceType: state.ResourceType, - TenantID: state.TenantID, - ProjectID: events.NilIfEmpty(state.ProjectID), - CurrentState: state.CurrentState, - DurationSeconds: durationSeconds, - BillingDimensions: comp.FlatBillingDimensions(), - SchemaVersion: "v1", - } - if err := ce.SetData(cloudevents.ApplicationJSON, data); err != nil { - return ce, fmt.Errorf("setting component heartbeat data: %w", err) - } - return ce, nil + return g.buildHeartbeatEvent(state, events.ComponentEventID(uuid.NewString(), comp), comp.FlatBillingDimensions(), now) } -func (g *Generator) buildHeartbeatEvent(state *projection.ResourceState, now time.Time) (cloudevents.Event, error) { +func (g *Generator) buildHeartbeatEvent(state *projection.ResourceState, eventID string, dims map[string]any, now time.Time) (cloudevents.Event, error) { ce := cloudevents.NewEvent() - ce.SetID(uuid.NewString()) + ce.SetID(eventID) ce.SetSource("osac-metering") ce.SetType("osac.resource.heartbeat.v1") ce.SetTime(now) @@ -204,7 +178,7 @@ func (g *Generator) buildHeartbeatEvent(state *projection.ResourceState, now tim ProjectID: events.NilIfEmpty(state.ProjectID), CurrentState: state.CurrentState, DurationSeconds: durationSeconds, - BillingDimensions: state.BillingDimensions, + BillingDimensions: dims, SchemaVersion: "v1", } if err := ce.SetData(cloudevents.ApplicationJSON, data); err != nil { diff --git a/osac-metering/metering-service/internal/reconciliation/reconciler.go b/osac-metering/metering-service/internal/reconciliation/reconciler.go index a08e25315..36f8abc20 100644 --- a/osac-metering/metering-service/internal/reconciliation/reconciler.go +++ b/osac-metering/metering-service/internal/reconciliation/reconciler.go @@ -258,10 +258,15 @@ func (r *Reconciler) reconcileFulfillmentResources(ctx context.Context, fulfillm func (r *Reconciler) reconcileMissedDeletions(ctx context.Context, fulfillmentState map[string]fulfillmentResource, projMap map[string]projection.ResourceState, now time.Time) (int, error) { corrections := 0 + clusterSkipLogged := false for id, ps := range projMap { if _, exists := fulfillmentState[id]; !exists { if ps.ResourceType == "cluster_order" && r.clusterClient == nil { + if !clusterSkipLogged { + r.logger.Info("skipping cluster_order missed deletion checks, no cluster client configured") + clusterSkipLogged = true + } continue } if err := r.publishCorrections(ctx, id, ps.ResourceType, ps.TenantID, ps.ProjectID, From da630d76a3c8d3a7f7c0281fc149497cde3676c4 Mon Sep 17 00:00:00 2001 From: omer-vishlitzky Date: Thu, 6 Aug 2026 09:50:26 +0300 Subject: [PATCH 06/18] OSAC-3410: Explicit state machines, NodeSet-keyed components, deterministic correction IDs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit State machines (CaaS + VMaaS): - CaaS: replace default updated.v1 with explicit non-billable→billable as resumed.v1. Default case now errors on unexpected transitions. - VMaaS: enumerate all known states explicitly (STARTING, STOPPING, DELETING, DELETE_FAILED, UNSPECIFIED). Default case errors. NodeSet-keyed components: - Add NodeSet field to ComponentRecord, populated from spec.node_sets map key. Prevents host_type collision when two pools share a type. - ComponentEventID uses NodeSet as unique key: {eventID}/{nodeSet} - ChangedComponents keys on NodeSet instead of component:host_type - FlatBillingDimensions includes node_set field Deterministic correction event IDs: - Derive base ID from resourceID + reason + states instead of uuid.NewString(). Same drift across reconciliation cycles produces same event IDs, enabling adapter-level dedup. Assisted-by: Claude Code Signed-off-by: omer-vishlitzky --- osac-metering/metering-service/go.mod | 4 +- osac-metering/metering-service/go.sum | 7 +- .../internal/events/cluster.go | 26 ++--- .../internal/events/cluster_test.go | 95 +++++++++++-------- .../internal/heartbeat/generator_test.go | 4 +- .../internal/reconciliation/correction.go | 4 +- .../reconciliation/reconciler_test.go | 8 +- .../internal/watch/consumer_test.go | 6 +- 8 files changed, 87 insertions(+), 67 deletions(-) diff --git a/osac-metering/metering-service/go.mod b/osac-metering/metering-service/go.mod index c75b82151..cd0f341a8 100644 --- a/osac-metering/metering-service/go.mod +++ b/osac-metering/metering-service/go.mod @@ -9,6 +9,7 @@ require ( github.com/go-logr/logr v1.4.4 github.com/go-logr/zapr v1.3.0 github.com/golang-migrate/migrate/v4 v4.19.1 + github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.10.0 github.com/onsi/ginkgo/v2 v2.32.0 github.com/onsi/gomega v1.42.1 @@ -31,7 +32,6 @@ require ( github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect github.com/jackc/pgpassfile v1.0.0 // indirect @@ -47,6 +47,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pierrec/lz4/v4 v4.1.27 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.70.1 // indirect @@ -55,6 +56,7 @@ require ( github.com/rogpeppe/go-internal v1.15.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.54.0 // indirect diff --git a/osac-metering/metering-service/go.sum b/osac-metering/metering-service/go.sum index 0744716d6..0e970ff65 100644 --- a/osac-metering/metering-service/go.sum +++ b/osac-metering/metering-service/go.sum @@ -104,6 +104,7 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= @@ -130,8 +131,7 @@ github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I= github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= -github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk= github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -181,8 +181,7 @@ github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gi github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= diff --git a/osac-metering/metering-service/internal/events/cluster.go b/osac-metering/metering-service/internal/events/cluster.go index 5a0a24dec..1506a4e15 100644 --- a/osac-metering/metering-service/internal/events/cluster.go +++ b/osac-metering/metering-service/internal/events/cluster.go @@ -112,10 +112,10 @@ func (m *clusterMapper) resolveUpdatedEventType(previousState string) (string, e previousBillable := IsClusterBillableState(previousState) switch { - case currentBillable && previousState == "FAILED": - return "osac.resource.resumed.v1", nil case currentBillable && previousState == "": return "osac.resource.started.v1", nil + case currentBillable && !previousBillable: + return "osac.resource.resumed.v1", nil case !currentBillable && previousBillable: return "osac.resource.suspended.v1", nil case currentBillable && previousBillable: @@ -123,7 +123,7 @@ func (m *clusterMapper) resolveUpdatedEventType(previousState string) (string, e case !currentBillable && !previousBillable: return "", ErrSkipNonBillingTransition default: - return "osac.resource.updated.v1", nil + return "", fmt.Errorf("unexpected cluster state transition: %s -> %s", previousState, currentState) } } @@ -182,6 +182,7 @@ func ClusterBillingDimensions(cl *privatev1.Cluster) map[string]any { // assertion works for both fresh dims and JSONB-round-tripped dims. components := []any{ map[string]any{ + "node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1), @@ -197,6 +198,7 @@ func ClusterBillingDimensions(cl *privatev1.Cluster) map[string]any { for _, k := range keys { ns := nodeSets[k] components = append(components, map[string]any{ + "node_set": k, "component": "worker", "host_type": ns.GetHostType(), "node_count": ns.GetSize(), @@ -210,6 +212,7 @@ func ClusterBillingDimensions(cl *privatev1.Cluster) map[string]any { // ComponentRecord represents one billing record in the N+1 decomposition. type ComponentRecord struct { + NodeSet string Component string HostType string NodeCount int32 @@ -222,6 +225,7 @@ type ComponentRecord struct { func (cr ComponentRecord) FlatBillingDimensions() map[string]any { dims := map[string]any{ "cluster_template": cr.ClusterTemplate, + "node_set": cr.NodeSet, "component": cr.Component, "host_type": cr.HostType, "node_count": cr.NodeCount, @@ -255,6 +259,7 @@ func DecomposeClusterComponents(billingDims map[string]any) []ComponentRecord { if !ok { continue } + nodeSet, _ := cm["node_set"].(string) component, _ := cm["component"].(string) hostType, _ := cm["host_type"].(string) @@ -264,6 +269,7 @@ func DecomposeClusterComponents(billingDims map[string]any) []ComponentRecord { } records = append(records, ComponentRecord{ + NodeSet: nodeSet, Component: component, HostType: hostType, NodeCount: nodeCount, @@ -276,9 +282,9 @@ func DecomposeClusterComponents(billingDims map[string]any) []ComponentRecord { } // ComponentEventID derives a deterministic CloudEvent ID for a decomposed -// component event. Deterministic IDs enable adapter-level dedup on replay. +// component event. Uses NodeSet as the unique key per cluster. func ComponentEventID(baseEventID string, comp ComponentRecord) string { - return fmt.Sprintf("%s/%s:%s", baseEventID, comp.Component, comp.HostType) + return fmt.Sprintf("%s/%s", baseEventID, comp.NodeSet) } // ChangedComponents compares old and new billing dimensions and returns @@ -290,23 +296,21 @@ func ChangedComponents(oldDims, newDims map[string]any) []ComponentRecord { oldByKey := make(map[string]ComponentRecord, len(oldRecords)) for _, r := range oldRecords { - oldByKey[r.Component+":"+r.HostType] = r + oldByKey[r.NodeSet] = r } newByKey := make(map[string]bool, len(newRecords)) var changed []ComponentRecord for _, r := range newRecords { - key := r.Component + ":" + r.HostType - newByKey[key] = true - old, exists := oldByKey[key] + newByKey[r.NodeSet] = true + old, exists := oldByKey[r.NodeSet] if !exists || old.NodeCount != r.NodeCount { changed = append(changed, r) } } for _, r := range oldRecords { - key := r.Component + ":" + r.HostType - if !newByKey[key] { + if !newByKey[r.NodeSet] { changed = append(changed, ComponentRecord{ Component: r.Component, HostType: r.HostType, diff --git a/osac-metering/metering-service/internal/events/cluster_test.go b/osac-metering/metering-service/internal/events/cluster_test.go index 8e4e7fafa..b5983f0a2 100644 --- a/osac-metering/metering-service/internal/events/cluster_test.go +++ b/osac-metering/metering-service/internal/events/cluster_test.go @@ -221,6 +221,7 @@ var _ = Describe("CaaS Cluster Mapper", func() { Expect(components).To(HaveLen(3)) cp := components[0].(map[string]any) + Expect(cp["node_set"]).To(Equal("_control_plane")) Expect(cp["component"]).To(Equal("control_plane")) Expect(cp["host_type"]).To(Equal("_control_plane")) Expect(cp["node_count"]).To(Equal(int32(1))) @@ -232,9 +233,11 @@ var _ = Describe("CaaS Cluster Mapper", func() { // control_plane first, then sorted by node set key: "cpu-workers" < "gpu-workers" w1 := components[1].(map[string]any) + Expect(w1["node_set"]).To(Equal("cpu-workers")) Expect(w1["host_type"]).To(Equal("cpu-only")) Expect(w1["node_count"]).To(Equal(int32(3))) w2 := components[2].(map[string]any) + Expect(w2["node_set"]).To(Equal("gpu-workers")) Expect(w2["host_type"]).To(Equal("gpu-h100")) Expect(w2["node_count"]).To(Equal(int32(2))) }) @@ -257,6 +260,7 @@ var _ = Describe("CaaS Cluster Mapper", func() { components := dims["components"].([]any) Expect(components).To(HaveLen(1)) cp := components[0].(map[string]any) + Expect(cp["node_set"]).To(Equal("_control_plane")) Expect(cp["component"]).To(Equal("control_plane")) }) @@ -264,9 +268,12 @@ var _ = Describe("CaaS Cluster Mapper", func() { dims := events.ClusterBillingDimensions(cl) records := events.DecomposeClusterComponents(dims) Expect(records).To(HaveLen(3)) + Expect(records[0].NodeSet).To(Equal("_control_plane")) Expect(records[0].Component).To(Equal("control_plane")) Expect(records[0].NodeCount).To(Equal(int32(1))) + Expect(records[1].NodeSet).To(Equal("cpu-workers")) Expect(records[1].Component).To(Equal("worker")) + Expect(records[2].NodeSet).To(Equal("gpu-workers")) Expect(records[2].Component).To(Equal("worker")) }) }) @@ -358,25 +365,28 @@ var _ = Describe("DecomposeClusterComponents", func() { "cluster_template": "ocp-ci-small", "release_image": "quay.io/ocp:4.17.0", "components": []any{ - map[string]any{"component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, - map[string]any{"component": "worker", "host_type": "cpu-only", "node_count": int32(3)}, - map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, + map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + map[string]any{"node_set": "cpu-workers", "component": "worker", "host_type": "cpu-only", "node_count": int32(3)}, + map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, }, } records := events.DecomposeClusterComponents(dims) Expect(records).To(HaveLen(3)) + Expect(records[0].NodeSet).To(Equal("_control_plane")) Expect(records[0].Component).To(Equal("control_plane")) Expect(records[0].HostType).To(Equal("_control_plane")) Expect(records[0].NodeCount).To(Equal(int32(1))) Expect(records[0].ClusterTemplate).To(Equal("ocp-ci-small")) Expect(records[0].ReleaseImage).To(Equal("quay.io/ocp:4.17.0")) + Expect(records[1].NodeSet).To(Equal("cpu-workers")) Expect(records[1].Component).To(Equal("worker")) Expect(records[1].HostType).To(Equal("cpu-only")) Expect(records[1].NodeCount).To(Equal(int32(3))) + Expect(records[2].NodeSet).To(Equal("gpu-workers")) Expect(records[2].Component).To(Equal("worker")) Expect(records[2].HostType).To(Equal("gpu-h100")) Expect(records[2].NodeCount).To(Equal(int32(2))) @@ -386,8 +396,8 @@ var _ = Describe("DecomposeClusterComponents", func() { dims := map[string]any{ "cluster_template": "tmpl", "components": []any{ - map[string]any{"component": "control_plane", "host_type": "_control_plane", "node_count": float64(1)}, - map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": float64(2)}, + map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": float64(1)}, + map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": float64(2)}, }, } @@ -410,6 +420,7 @@ var _ = Describe("DecomposeClusterComponents", func() { var _ = Describe("ComponentRecord", func() { It("produces flat billing dimensions", func() { cr := events.ComponentRecord{ + NodeSet: "gpu-workers", Component: "worker", HostType: "gpu-h100", NodeCount: 2, @@ -420,6 +431,7 @@ var _ = Describe("ComponentRecord", func() { flat := cr.FlatBillingDimensions() Expect(flat["cluster_template"]).To(Equal("ocp-ci-small")) Expect(flat["release_image"]).To(Equal("quay.io/ocp:4.17.0")) + Expect(flat["node_set"]).To(Equal("gpu-workers")) Expect(flat["component"]).To(Equal("worker")) Expect(flat["host_type"]).To(Equal("gpu-h100")) Expect(flat["node_count"]).To(Equal(int32(2))) @@ -427,6 +439,7 @@ var _ = Describe("ComponentRecord", func() { It("omits release_image when empty", func() { cr := events.ComponentRecord{ + NodeSet: "_control_plane", Component: "control_plane", HostType: "_control_plane", NodeCount: 1, @@ -440,21 +453,21 @@ var _ = Describe("ComponentRecord", func() { var _ = Describe("ComponentEventID", func() { It("produces deterministic IDs", func() { - comp := events.ComponentRecord{Component: "worker", HostType: "gpu-h100"} + comp := events.ComponentRecord{NodeSet: "gpu-workers", Component: "worker", HostType: "gpu-h100"} id1 := events.ComponentEventID("evt-123", comp) id2 := events.ComponentEventID("evt-123", comp) Expect(id1).To(Equal(id2)) - Expect(id1).To(Equal("evt-123/worker:gpu-h100")) + Expect(id1).To(Equal("evt-123/gpu-workers")) }) It("produces different IDs for different components", func() { - cp := events.ComponentRecord{Component: "control_plane", HostType: "_control_plane"} - worker := events.ComponentRecord{Component: "worker", HostType: "gpu-h100"} + cp := events.ComponentRecord{NodeSet: "_control_plane", Component: "control_plane", HostType: "_control_plane"} + worker := events.ComponentRecord{NodeSet: "gpu-workers", Component: "worker", HostType: "gpu-h100"} Expect(events.ComponentEventID("evt-1", cp)).NotTo(Equal(events.ComponentEventID("evt-1", worker))) }) It("produces different IDs for different base events", func() { - comp := events.ComponentRecord{Component: "worker", HostType: "gpu-h100"} + comp := events.ComponentRecord{NodeSet: "gpu-workers", Component: "worker", HostType: "gpu-h100"} Expect(events.ComponentEventID("evt-1", comp)).NotTo(Equal(events.ComponentEventID("evt-2", comp))) }) }) @@ -464,15 +477,15 @@ var _ = Describe("ChangedComponents", func() { oldDims := map[string]any{ "cluster_template": "tmpl", "components": []any{ - map[string]any{"component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, - map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, + map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, }, } newDims := map[string]any{ "cluster_template": "tmpl", "components": []any{ - map[string]any{"component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, - map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": int32(4)}, + map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": int32(4)}, }, } @@ -486,7 +499,7 @@ var _ = Describe("ChangedComponents", func() { dims := map[string]any{ "cluster_template": "tmpl", "components": []any{ - map[string]any{"component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, }, } @@ -497,14 +510,14 @@ var _ = Describe("ChangedComponents", func() { oldDims := map[string]any{ "cluster_template": "tmpl", "components": []any{ - map[string]any{"component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, }, } newDims := map[string]any{ "cluster_template": "tmpl", "components": []any{ - map[string]any{"component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, - map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, + map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, }, } @@ -517,14 +530,14 @@ var _ = Describe("ChangedComponents", func() { oldDims := map[string]any{ "cluster_template": "tmpl", "components": []any{ - map[string]any{"component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, - map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, + map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, }, } newDims := map[string]any{ "cluster_template": "tmpl", "components": []any{ - map[string]any{"component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, }, } @@ -538,13 +551,13 @@ var _ = Describe("ChangedComponents", func() { oldDims := map[string]any{ "cluster_template": "tmpl", "components": []any{ - map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, + map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, }, } newDims := map[string]any{ "cluster_template": "tmpl", "components": []any{ - map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": float64(2)}, + map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": float64(2)}, }, } @@ -558,16 +571,16 @@ var _ = Describe("DimensionsEqual with nested CaaS components", func() { "cluster_template": "ocp-ci-small", "release_image": "quay.io/ocp:4.17.0", "components": []any{ - map[string]any{"component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, - map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, + map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, }, } b := map[string]any{ "cluster_template": "ocp-ci-small", "release_image": "quay.io/ocp:4.17.0", "components": []any{ - map[string]any{"component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, - map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, + map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, }, } Expect(events.DimensionsEqual(a, b)).To(BeTrue()) @@ -577,13 +590,13 @@ var _ = Describe("DimensionsEqual with nested CaaS components", func() { a := map[string]any{ "cluster_template": "tmpl", "components": []any{ - map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, + map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, }, } b := map[string]any{ "cluster_template": "tmpl", "components": []any{ - map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": int32(4)}, + map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": int32(4)}, }, } Expect(events.DimensionsEqual(a, b)).To(BeFalse()) @@ -593,14 +606,14 @@ var _ = Describe("DimensionsEqual with nested CaaS components", func() { a := map[string]any{ "cluster_template": "tmpl", "components": []any{ - map[string]any{"component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, }, } b := map[string]any{ "cluster_template": "tmpl", "components": []any{ - map[string]any{"component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, - map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, + map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, }, } Expect(events.DimensionsEqual(a, b)).To(BeFalse()) @@ -611,8 +624,8 @@ var _ = Describe("DimensionsEqual with nested CaaS components", func() { "cluster_template": "tmpl", "release_image": "quay.io/ocp:4.17.0", "components": []any{ - map[string]any{"component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, - map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, + map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, }, } @@ -631,7 +644,7 @@ var _ = Describe("DimensionsEqual with nested CaaS components", func() { stored := map[string]any{ "cluster_template": "tmpl", "components": []any{ - map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, + map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, }, } data, err := json.Marshal(stored) @@ -644,7 +657,7 @@ var _ = Describe("DimensionsEqual with nested CaaS components", func() { incoming := map[string]any{ "cluster_template": "tmpl", "components": []any{ - map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": int32(4)}, + map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": int32(4)}, }, } @@ -656,9 +669,9 @@ var _ = Describe("DimensionsEqual with nested CaaS components", func() { "cluster_template": "ocp-ci-small", "release_image": "quay.io/ocp:4.17.0", "components": []any{ - map[string]any{"component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, - map[string]any{"component": "worker", "host_type": "cpu-only", "node_count": int32(3)}, - map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, + map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + map[string]any{"node_set": "cpu-workers", "component": "worker", "host_type": "cpu-only", "node_count": int32(3)}, + map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, }, } @@ -680,13 +693,13 @@ var _ = Describe("DimensionsEqual with nested CaaS components", func() { a := map[string]any{ "cluster_template": "tmpl", "components": []any{ - map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, + map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, }, } b := map[string]any{ "cluster_template": "tmpl", "components": []any{ - map[string]any{"component": "worker", "host_type": "gpu-a100", "node_count": int32(2)}, + map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-a100", "node_count": int32(2)}, }, } Expect(events.DimensionsEqual(a, b)).To(BeFalse()) diff --git a/osac-metering/metering-service/internal/heartbeat/generator_test.go b/osac-metering/metering-service/internal/heartbeat/generator_test.go index 436e98af6..7bb1fb971 100644 --- a/osac-metering/metering-service/internal/heartbeat/generator_test.go +++ b/osac-metering/metering-service/internal/heartbeat/generator_test.go @@ -271,8 +271,8 @@ var _ = Describe("Generator", func() { "cluster_template": "ocp-ci-small", "release_image": "quay.io/ocp:4.17.0", "components": []any{ - map[string]any{"component": "control_plane", "host_type": "_control_plane", "node_count": float64(1)}, - map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": float64(2)}, + map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": float64(1)}, + map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": float64(2)}, }, }, } diff --git a/osac-metering/metering-service/internal/reconciliation/correction.go b/osac-metering/metering-service/internal/reconciliation/correction.go index 954882cda..0a3d8834c 100644 --- a/osac-metering/metering-service/internal/reconciliation/correction.go +++ b/osac-metering/metering-service/internal/reconciliation/correction.go @@ -73,6 +73,7 @@ func buildCorrectionEvents( interval *AffectedInterval, now time.Time, ) ([]cloudevents.Event, error) { + baseID := fmt.Sprintf("correction/%s/%s/%s/%s", resourceID, reason, projectionState, sourceState) if resourceType == "cluster_order" { components := events.DecomposeClusterComponents(billingDimensions) if len(components) > 0 { @@ -83,7 +84,7 @@ func buildCorrectionEvents( if err != nil { return nil, err } - ce.SetID(events.ComponentEventID(ce.ID(), comp)) + ce.SetID(events.ComponentEventID(baseID, comp)) result = append(result, ce) } return result, nil @@ -94,6 +95,7 @@ func buildCorrectionEvents( if err != nil { return nil, err } + ce.SetID(baseID) return []cloudevents.Event{ce}, nil } diff --git a/osac-metering/metering-service/internal/reconciliation/reconciler_test.go b/osac-metering/metering-service/internal/reconciliation/reconciler_test.go index f280abcb7..6df9ae73f 100644 --- a/osac-metering/metering-service/internal/reconciliation/reconciler_test.go +++ b/osac-metering/metering-service/internal/reconciliation/reconciler_test.go @@ -733,8 +733,8 @@ var _ = Describe("Reconciler", func() { "cluster_template": "ocp-ci-small", "release_image": "quay.io/ocp:4.17.0", "components": []any{ - map[string]any{"component": "control_plane", "host_type": "_control_plane", "node_count": float64(1)}, - map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": float64(2)}, + map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": float64(1)}, + map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": float64(2)}, }, }, } @@ -779,8 +779,8 @@ var _ = Describe("Reconciler", func() { BillingDimensions: map[string]any{ "cluster_template": "ocp-ci-small", "components": []any{ - map[string]any{"component": "control_plane", "host_type": "_control_plane", "node_count": float64(1)}, - map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": float64(2)}, + map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": float64(1)}, + map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": float64(2)}, }, }, } diff --git a/osac-metering/metering-service/internal/watch/consumer_test.go b/osac-metering/metering-service/internal/watch/consumer_test.go index d18cd8a3e..7178d512a 100644 --- a/osac-metering/metering-service/internal/watch/consumer_test.go +++ b/osac-metering/metering-service/internal/watch/consumer_test.go @@ -842,9 +842,9 @@ var _ = Describe("Consumer", func() { "cluster_template": "ocp-ci-small", "release_image": "quay.io/ocp:4.17.0", "components": []any{ - map[string]any{"component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, - map[string]any{"component": "worker", "host_type": "cpu-only", "node_count": int32(3)}, - map[string]any{"component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, + map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + map[string]any{"node_set": "cpu-workers", "component": "worker", "host_type": "cpu-only", "node_count": int32(3)}, + map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, }, } } From 321d19872a9fdaa9177b05c3b8d47c8b389e2d3d Mon Sep 17 00:00:00 2001 From: omer-vishlitzky Date: Thu, 6 Aug 2026 09:59:44 +0300 Subject: [PATCH 07/18] OSAC-3410: Sync go.sum and go.work.sum after module changes Assisted-by: Claude Code Signed-off-by: omer-vishlitzky --- osac-metering/metering-service/go.sum | 3 +++ 1 file changed, 3 insertions(+) diff --git a/osac-metering/metering-service/go.sum b/osac-metering/metering-service/go.sum index 0e970ff65..aa580131a 100644 --- a/osac-metering/metering-service/go.sum +++ b/osac-metering/metering-service/go.sum @@ -105,6 +105,7 @@ github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3x github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= @@ -132,6 +133,7 @@ github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEB github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk= github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -182,6 +184,7 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= From 2e8c6d5b0442206c29936b4f7944f07854aac880 Mon Sep 17 00:00:00 2001 From: omer-vishlitzky Date: Thu, 6 Aug 2026 10:01:17 +0300 Subject: [PATCH 08/18] OSAC-3410: Error on unknown values in all switch defaults - correctionDescription returns error for unknown CorrectionReason - isBillableForType returns error for unknown resource type - No silent fallbacks remain in any switch statement Assisted-by: Claude Code Signed-off-by: omer-vishlitzky --- .../internal/reconciliation/correction.go | 19 ++++++++++++------- .../internal/reconciliation/reconciler.go | 18 ++++++++++++------ 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/osac-metering/metering-service/internal/reconciliation/correction.go b/osac-metering/metering-service/internal/reconciliation/correction.go index 0a3d8834c..648599b1f 100644 --- a/osac-metering/metering-service/internal/reconciliation/correction.go +++ b/osac-metering/metering-service/internal/reconciliation/correction.go @@ -50,18 +50,18 @@ type correctionData struct { SchemaVersion string `json:"schema_version"` } -func correctionDescription(reason CorrectionReason) string { +func correctionDescription(reason CorrectionReason) (string, error) { switch reason { case MissedCreation: - return "Resource found in fulfillment-service but missing from metering projection" + return "Resource found in fulfillment-service but missing from metering projection", nil case StateDrift: - return "Resource state in fulfillment-service differs from metering projection" + return "Resource state in fulfillment-service differs from metering projection", nil case BillingDimensionsDrift: - return "Billing dimensions in fulfillment-service differ from metering projection" + return "Billing dimensions in fulfillment-service differ from metering projection", nil case MissedDeletion: - return "Resource found in metering projection but missing from fulfillment-service" + return "Resource found in metering projection but missing from fulfillment-service", nil default: - return fmt.Sprintf("unknown correction reason: %s", reason) + return "", fmt.Errorf("unknown correction reason: %s", reason) } } @@ -114,13 +114,18 @@ func buildCorrectionEvent( ce.SetTime(now) events.SetOSACExtensions(&ce, resourceID, resourceType, tenantID, projectID) + description, err := correctionDescription(reason) + if err != nil { + return ce, err + } + data := correctionData{ ResourceID: resourceID, ResourceType: resourceType, TenantID: tenantID, ProjectID: events.NilIfEmpty(projectID), Reason: reason, - Description: correctionDescription(reason), + Description: description, CorrectedState: events.NilIfEmpty(sourceState), PreviousStateProjection: events.NilIfEmpty(projectionState), ActualStateFromSource: events.NilIfEmpty(sourceState), diff --git a/osac-metering/metering-service/internal/reconciliation/reconciler.go b/osac-metering/metering-service/internal/reconciliation/reconciler.go index 36f8abc20..d21422f9f 100644 --- a/osac-metering/metering-service/internal/reconciliation/reconciler.go +++ b/osac-metering/metering-service/internal/reconciliation/reconciler.go @@ -163,7 +163,10 @@ func (r *Reconciler) reconcileFulfillmentResources(ctx context.Context, fulfillm } corrections++ - isBillable := isBillableForType(fs.resourceType, fs.state) + isBillable, billErr := isBillableForType(fs.resourceType, fs.state) + if billErr != nil { + return corrections, fmt.Errorf("checking billability for %s: %w", id, billErr) + } newState := projection.ResourceState{ ResourceID: id, ResourceType: fs.resourceType, @@ -213,7 +216,10 @@ func (r *Reconciler) reconcileFulfillmentResources(ctx context.Context, fulfillm } corrections++ - isBillable := isBillableForType(fs.resourceType, fs.state) + isBillable, billErr := isBillableForType(fs.resourceType, fs.state) + if billErr != nil { + return corrections, fmt.Errorf("checking billability for %s: %w", id, billErr) + } ps.PreviousState = ps.CurrentState ps.CurrentState = fs.state wasBillable := ps.IsBillable @@ -355,14 +361,14 @@ type fulfillmentResource struct { billingDimensions map[string]any } -func isBillableForType(resourceType, state string) bool { +func isBillableForType(resourceType, state string) (bool, error) { switch resourceType { case "compute_instance": - return events.IsBillableState(state) + return events.IsBillableState(state), nil case "cluster_order": - return events.IsClusterBillableState(state) + return events.IsClusterBillableState(state), nil default: - return false + return false, fmt.Errorf("unknown resource type: %s", resourceType) } } From 7a7834500b71253a0a3374fce14d8251b3856a13 Mon Sep 17 00:00:00 2001 From: omer-vishlitzky Date: Thu, 6 Aug 2026 10:53:57 +0300 Subject: [PATCH 09/18] OSAC-3410: Fix removal NodeSet, deterministic synthetic heartbeat IDs, proto compat - ChangedComponents: preserve NodeSet on removed component records so ComponentEventID produces unique IDs and FlatBillingDimensions carries the correct node_set for billing closure - buildSyntheticHeartbeats: derive deterministic base ID from resourceID + timestamp instead of uuid.NewString, enabling adapter dedup on retry - Adapt to typed proto references (ClusterTemplateReference, ClusterCatalogItemReference, HostTypeReference, VersionName) - Fix goimports formatting Assisted-by: Claude Code Signed-off-by: omer-vishlitzky --- .../internal/events/cluster.go | 15 ++-- .../internal/events/cluster_test.go | 75 +++++++++++++------ .../internal/heartbeat/generator_test.go | 14 ++-- .../internal/reconciliation/reconciler.go | 6 +- .../reconciliation/reconciler_test.go | 72 +++++++++++++++--- .../internal/watch/consumer.go | 2 +- .../internal/watch/consumer_test.go | 20 ++--- 7 files changed, 145 insertions(+), 59 deletions(-) diff --git a/osac-metering/metering-service/internal/events/cluster.go b/osac-metering/metering-service/internal/events/cluster.go index 1506a4e15..3b4c3b72e 100644 --- a/osac-metering/metering-service/internal/events/cluster.go +++ b/osac-metering/metering-service/internal/events/cluster.go @@ -200,7 +200,7 @@ func ClusterBillingDimensions(cl *privatev1.Cluster) map[string]any { components = append(components, map[string]any{ "node_set": k, "component": "worker", - "host_type": ns.GetHostType(), + "host_type": ns.GetHostType().GetName(), "node_count": ns.GetSize(), }) } @@ -217,7 +217,7 @@ type ComponentRecord struct { HostType string NodeCount int32 ClusterTemplate string - ReleaseImage string + VersionName string } // FlatBillingDimensions returns per-component billing dimensions for a single @@ -230,8 +230,8 @@ func (cr ComponentRecord) FlatBillingDimensions() map[string]any { "host_type": cr.HostType, "node_count": cr.NodeCount, } - if cr.ReleaseImage != "" { - dims["release_image"] = cr.ReleaseImage + if cr.VersionName != "" { + dims["version_name"] = cr.VersionName } return dims } @@ -241,7 +241,7 @@ func (cr ComponentRecord) FlatBillingDimensions() map[string]any { // Reconciler to fan out one cluster into per-component events. func DecomposeClusterComponents(billingDims map[string]any) []ComponentRecord { clusterTemplate, _ := billingDims["cluster_template"].(string) - releaseImage, _ := billingDims["release_image"].(string) + versionName, _ := billingDims["version_name"].(string) componentsRaw, ok := billingDims["components"] if !ok { @@ -274,7 +274,7 @@ func DecomposeClusterComponents(billingDims map[string]any) []ComponentRecord { HostType: hostType, NodeCount: nodeCount, ClusterTemplate: clusterTemplate, - ReleaseImage: releaseImage, + VersionName: versionName, }) } @@ -312,11 +312,12 @@ func ChangedComponents(oldDims, newDims map[string]any) []ComponentRecord { for _, r := range oldRecords { if !newByKey[r.NodeSet] { changed = append(changed, ComponentRecord{ + NodeSet: r.NodeSet, Component: r.Component, HostType: r.HostType, NodeCount: 0, ClusterTemplate: r.ClusterTemplate, - ReleaseImage: r.ReleaseImage, + VersionName: r.VersionName, }) } } diff --git a/osac-metering/metering-service/internal/events/cluster_test.go b/osac-metering/metering-service/internal/events/cluster_test.go index b5983f0a2..9a56b2782 100644 --- a/osac-metering/metering-service/internal/events/cluster_test.go +++ b/osac-metering/metering-service/internal/events/cluster_test.go @@ -24,15 +24,15 @@ var _ = Describe("CaaS Cluster Mapper", func() { Tenant: "tenant-1", Project: "project-alpha", Version: 5, - CreationTimestamp: timestamppb.Now(), + CreationTimestamp: timestamppb.Now(), }, Spec: &privatev1.ClusterSpec{ - Template: "ocp-ci-small", - CatalogItem: "cluster-catalog-1", - ReleaseImage: strPtr("quay.io/openshift-release-dev/ocp-release:4.17.0-x86_64"), + Template: &privatev1.ClusterTemplateReference{Id: "ocp-ci-small", Name: "ocp-ci-small"}, + CatalogItem: &privatev1.ClusterCatalogItemReference{Id: "cluster-catalog-1", Name: "cluster-catalog-1"}, + VersionName: strPtr("quay.io/openshift-release-dev/ocp-release:4.17.0-x86_64"), NodeSets: map[string]*privatev1.ClusterNodeSet{ - "gpu-workers": {HostType: "gpu-h100", Size: 2}, - "cpu-workers": {HostType: "cpu-only", Size: 3}, + "gpu-workers": {HostType: &privatev1.HostTypeReference{Name: "gpu-h100"}, Size: 2}, + "cpu-workers": {HostType: &privatev1.HostTypeReference{Name: "cpu-only"}, Size: 3}, }, }, Status: &privatev1.ClusterStatus{ @@ -211,10 +211,10 @@ var _ = Describe("CaaS Cluster Mapper", func() { }) Context("billing dimensions", func() { - It("includes cluster_template, release_image, and full components breakdown", func() { + It("includes cluster_template, version_name, and full components breakdown", func() { dims := events.ClusterBillingDimensions(cl) Expect(dims["cluster_template"]).To(Equal("ocp-ci-small")) - Expect(dims["release_image"]).To(Equal("quay.io/openshift-release-dev/ocp-release:4.17.0-x86_64")) + Expect(dims["version_name"]).To(Equal("quay.io/openshift-release-dev/ocp-release:4.17.0-x86_64")) components, ok := dims["components"].([]any) Expect(ok).To(BeTrue(), "components must be []any for DecomposeClusterComponents compatibility") @@ -242,10 +242,10 @@ var _ = Describe("CaaS Cluster Mapper", func() { Expect(w2["node_count"]).To(Equal(int32(2))) }) - It("omits release_image when nil", func() { - cl.Spec.ReleaseImage = nil + It("omits version_name when nil", func() { + cl.Spec.VersionName = nil dims := events.ClusterBillingDimensions(cl) - Expect(dims).NotTo(HaveKey("release_image")) + Expect(dims).NotTo(HaveKey("version_name")) }) It("handles nil spec gracefully", func() { @@ -363,7 +363,7 @@ var _ = Describe("DecomposeClusterComponents", func() { It("decomposes 1 control plane + 2 worker sets into 3 records", func() { dims := map[string]any{ "cluster_template": "ocp-ci-small", - "release_image": "quay.io/ocp:4.17.0", + "version_name": "quay.io/ocp:4.17.0", "components": []any{ map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, map[string]any{"node_set": "cpu-workers", "component": "worker", "host_type": "cpu-only", "node_count": int32(3)}, @@ -379,7 +379,7 @@ var _ = Describe("DecomposeClusterComponents", func() { Expect(records[0].HostType).To(Equal("_control_plane")) Expect(records[0].NodeCount).To(Equal(int32(1))) Expect(records[0].ClusterTemplate).To(Equal("ocp-ci-small")) - Expect(records[0].ReleaseImage).To(Equal("quay.io/ocp:4.17.0")) + Expect(records[0].VersionName).To(Equal("quay.io/ocp:4.17.0")) Expect(records[1].NodeSet).To(Equal("cpu-workers")) Expect(records[1].Component).To(Equal("worker")) @@ -425,19 +425,19 @@ var _ = Describe("ComponentRecord", func() { HostType: "gpu-h100", NodeCount: 2, ClusterTemplate: "ocp-ci-small", - ReleaseImage: "quay.io/ocp:4.17.0", + VersionName: "quay.io/ocp:4.17.0", } flat := cr.FlatBillingDimensions() Expect(flat["cluster_template"]).To(Equal("ocp-ci-small")) - Expect(flat["release_image"]).To(Equal("quay.io/ocp:4.17.0")) + Expect(flat["version_name"]).To(Equal("quay.io/ocp:4.17.0")) Expect(flat["node_set"]).To(Equal("gpu-workers")) Expect(flat["component"]).To(Equal("worker")) Expect(flat["host_type"]).To(Equal("gpu-h100")) Expect(flat["node_count"]).To(Equal(int32(2))) }) - It("omits release_image when empty", func() { + It("omits version_name when empty", func() { cr := events.ComponentRecord{ NodeSet: "_control_plane", Component: "control_plane", @@ -447,7 +447,7 @@ var _ = Describe("ComponentRecord", func() { } flat := cr.FlatBillingDimensions() - Expect(flat).NotTo(HaveKey("release_image")) + Expect(flat).NotTo(HaveKey("version_name")) }) }) @@ -545,6 +545,39 @@ var _ = Describe("ChangedComponents", func() { Expect(changed).To(HaveLen(1)) Expect(changed[0].HostType).To(Equal("gpu-h100")) Expect(changed[0].NodeCount).To(Equal(int32(0))) + Expect(changed[0].NodeSet).To(Equal("gpu-workers")) + }) + + It("preserves distinct NodeSet on multi-removal for unique event IDs", func() { + oldDims := map[string]any{ + "cluster_template": "tmpl", + "components": []any{ + map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + map[string]any{"node_set": "pool-a", "component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, + map[string]any{"node_set": "pool-b", "component": "worker", "host_type": "cpu-only", "node_count": int32(3)}, + }, + } + newDims := map[string]any{ + "cluster_template": "tmpl", + "components": []any{ + map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + }, + } + + changed := events.ChangedComponents(oldDims, newDims) + Expect(changed).To(HaveLen(2)) + + nodeSets := map[string]bool{} + for _, c := range changed { + Expect(c.NodeCount).To(Equal(int32(0))) + Expect(c.NodeSet).NotTo(BeEmpty()) + id := events.ComponentEventID("evt-1", c) + Expect(id).NotTo(Equal("evt-1/")) + nodeSets[c.NodeSet] = true + } + Expect(nodeSets).To(HaveLen(2)) + Expect(nodeSets).To(HaveKey("pool-a")) + Expect(nodeSets).To(HaveKey("pool-b")) }) It("handles int32 vs float64 from JSONB round-trip", func() { @@ -569,7 +602,7 @@ var _ = Describe("DimensionsEqual with nested CaaS components", func() { It("matches identical billing dimensions with components array", func() { a := map[string]any{ "cluster_template": "ocp-ci-small", - "release_image": "quay.io/ocp:4.17.0", + "version_name": "quay.io/ocp:4.17.0", "components": []any{ map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, @@ -577,7 +610,7 @@ var _ = Describe("DimensionsEqual with nested CaaS components", func() { } b := map[string]any{ "cluster_template": "ocp-ci-small", - "release_image": "quay.io/ocp:4.17.0", + "version_name": "quay.io/ocp:4.17.0", "components": []any{ map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, @@ -622,7 +655,7 @@ var _ = Describe("DimensionsEqual with nested CaaS components", func() { It("handles JSONB round-trip: int32 stored, float64 on read", func() { stored := map[string]any{ "cluster_template": "tmpl", - "release_image": "quay.io/ocp:4.17.0", + "version_name": "quay.io/ocp:4.17.0", "components": []any{ map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, @@ -667,7 +700,7 @@ var _ = Describe("DimensionsEqual with nested CaaS components", func() { It("round-trip preserves equality for multi-component clusters", func() { original := map[string]any{ "cluster_template": "ocp-ci-small", - "release_image": "quay.io/ocp:4.17.0", + "version_name": "quay.io/ocp:4.17.0", "components": []any{ map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, map[string]any{"node_set": "cpu-workers", "component": "worker", "host_type": "cpu-only", "node_count": int32(3)}, diff --git a/osac-metering/metering-service/internal/heartbeat/generator_test.go b/osac-metering/metering-service/internal/heartbeat/generator_test.go index 7bb1fb971..256aeef6e 100644 --- a/osac-metering/metering-service/internal/heartbeat/generator_test.go +++ b/osac-metering/metering-service/internal/heartbeat/generator_test.go @@ -260,16 +260,16 @@ var _ = Describe("Generator", func() { makeClusterBillableState := func(id string) projection.ResourceState { now := time.Now().UTC().Truncate(time.Microsecond) return projection.ResourceState{ - ResourceID: id, - ResourceType: "cluster_order", - TenantID: "tenant-1", - ProjectID: "project-1", - CurrentState: "READY", - IsBillable: true, + ResourceID: id, + ResourceType: "cluster_order", + TenantID: "tenant-1", + ProjectID: "project-1", + CurrentState: "READY", + IsBillable: true, BillableSince: &now, BillingDimensions: map[string]any{ "cluster_template": "ocp-ci-small", - "release_image": "quay.io/ocp:4.17.0", + "version_name": "4.17.0", "components": []any{ map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": float64(1)}, map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": float64(2)}, diff --git a/osac-metering/metering-service/internal/reconciliation/reconciler.go b/osac-metering/metering-service/internal/reconciliation/reconciler.go index d21422f9f..4031da585 100644 --- a/osac-metering/metering-service/internal/reconciliation/reconciler.go +++ b/osac-metering/metering-service/internal/reconciliation/reconciler.go @@ -18,7 +18,6 @@ import ( cloudevents "github.com/cloudevents/sdk-go/v2" "github.com/go-logr/logr" - "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" "google.golang.org/grpc" @@ -476,12 +475,13 @@ func (r *Reconciler) loadClusters(ctx context.Context, result map[string]fulfill } func buildSyntheticHeartbeats(ps projection.ResourceState, now time.Time) ([]cloudevents.Event, error) { + baseID := fmt.Sprintf("synthetic-hb/%s/%d", ps.ResourceID, now.UTC().Unix()) if ps.ResourceType == "cluster_order" { components := events.DecomposeClusterComponents(ps.BillingDimensions) if len(components) > 0 { result := make([]cloudevents.Event, 0, len(components)) for _, comp := range components { - ce, err := buildSingleSyntheticHeartbeat(ps, comp.FlatBillingDimensions(), events.ComponentEventID(uuid.NewString(), comp), now) + ce, err := buildSingleSyntheticHeartbeat(ps, comp.FlatBillingDimensions(), events.ComponentEventID(baseID, comp), now) if err != nil { return nil, err } @@ -490,7 +490,7 @@ func buildSyntheticHeartbeats(ps projection.ResourceState, now time.Time) ([]clo return result, nil } } - ce, err := buildSingleSyntheticHeartbeat(ps, ps.BillingDimensions, uuid.NewString(), now) + ce, err := buildSingleSyntheticHeartbeat(ps, ps.BillingDimensions, baseID, now) if err != nil { return nil, err } diff --git a/osac-metering/metering-service/internal/reconciliation/reconciler_test.go b/osac-metering/metering-service/internal/reconciliation/reconciler_test.go index 6df9ae73f..feb7a402d 100644 --- a/osac-metering/metering-service/internal/reconciliation/reconciler_test.go +++ b/osac-metering/metering-service/internal/reconciliation/reconciler_test.go @@ -655,7 +655,7 @@ var _ = Describe("Reconciler", func() { Describe("CaaS cluster reconciliation", func() { makeClusterProto := func(id, tenant string, state privatev1.ClusterState, version int32) *privatev1.Cluster { - releaseImage := "quay.io/ocp:4.17.0" + versionName := "4.17.0" return &privatev1.Cluster{ Id: id, Metadata: &privatev1.Metadata{ @@ -663,10 +663,10 @@ var _ = Describe("Reconciler", func() { Version: version, }, Spec: &privatev1.ClusterSpec{ - Template: "ocp-ci-small", - ReleaseImage: &releaseImage, + Template: &privatev1.ClusterTemplateReference{Name: "ocp-ci-small"}, + VersionName: &versionName, NodeSets: map[string]*privatev1.ClusterNodeSet{ - "gpu-workers": {HostType: "gpu-h100", Size: 2}, + "gpu-workers": {HostType: &privatev1.HostTypeReference{Name: "gpu-h100"}, Size: 2}, }, }, Status: &privatev1.ClusterStatus{State: state}, @@ -731,7 +731,7 @@ var _ = Describe("Reconciler", func() { FulfillmentVersion: 1, BillingDimensions: map[string]any{ "cluster_template": "ocp-ci-small", - "release_image": "quay.io/ocp:4.17.0", + "version_name": "4.17.0", "components": []any{ map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": float64(1)}, map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": float64(2)}, @@ -770,11 +770,11 @@ var _ = Describe("Reconciler", func() { store := newMockStore() now := time.Now().UTC().Truncate(time.Microsecond) store.states["cl-gone"] = projection.ResourceState{ - ResourceID: "cl-gone", - ResourceType: "cluster_order", - TenantID: "tenant-1", - CurrentState: "READY", - IsBillable: true, + ResourceID: "cl-gone", + ResourceType: "cluster_order", + TenantID: "tenant-1", + CurrentState: "READY", + IsBillable: true, BillableSince: &now, BillingDimensions: map[string]any{ "cluster_template": "ocp-ci-small", @@ -862,5 +862,57 @@ var _ = Describe("Reconciler", func() { defer store.mu.Unlock() Expect(store.states).To(HaveLen(600)) }) + + It("produces deterministic synthetic heartbeat IDs for clusters", func() { + computeClient := &mockComputeClient{} + clusterClient := &mockClusterClient{ + items: []*privatev1.Cluster{ + makeClusterProto("cl-hb", "tenant-1", privatev1.ClusterState_CLUSTER_STATE_READY, 1), + }, + } + store := newMockStore() + now := time.Now().Add(-5 * time.Minute).UTC().Truncate(time.Microsecond) + store.states["cl-hb"] = projection.ResourceState{ + ResourceID: "cl-hb", + ResourceType: "cluster_order", + TenantID: "tenant-1", + CurrentState: "READY", + IsBillable: true, + BillableSince: &now, + FulfillmentVersion: 1, + BillingDimensions: map[string]any{ + "cluster_template": "ocp-ci-small", + "version_name": "4.17.0", + "components": []any{ + map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": float64(1)}, + map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": float64(2)}, + }, + }, + } + + pub := &mockPublisher{} + recon := reconciliation.NewReconciler(computeClient, clusterClient, store, pub, logr.Discard(), 60*time.Second) + + Expect(recon.Reconcile(ctx)).To(Succeed()) + + pub.mu.Lock() + defer pub.mu.Unlock() + + var hbEvents []cloudevents.Event + for _, e := range pub.published { + if e.Type() == "osac.resource.heartbeat.v1" { + hbEvents = append(hbEvents, e) + } + } + Expect(hbEvents).To(HaveLen(2)) + + ids := map[string]bool{} + for _, e := range hbEvents { + Expect(e.ID()).To(ContainSubstring("synthetic-hb/cl-hb/")) + Expect(e.ID()).NotTo(ContainSubstring("synthetic-hb/cl-hb//")) + ids[e.ID()] = true + } + Expect(ids).To(HaveLen(2)) + }) }) }) diff --git a/osac-metering/metering-service/internal/watch/consumer.go b/osac-metering/metering-service/internal/watch/consumer.go index 5f82ee385..b4d70d7e3 100644 --- a/osac-metering/metering-service/internal/watch/consumer.go +++ b/osac-metering/metering-service/internal/watch/consumer.go @@ -35,7 +35,7 @@ const ( defaultInitialDelay = 1 * time.Second defaultMaxDelay = 30 * time.Second defaultHandlerRetries = 3 - meteringFilter = "has(event.compute_instance) || has(event.cluster)" + meteringFilter = "has(event.compute_instance) || has(event.cluster)" ) // Consumer connects to the fulfillment-service gRPC Watch stream, maps diff --git a/osac-metering/metering-service/internal/watch/consumer_test.go b/osac-metering/metering-service/internal/watch/consumer_test.go index 7178d512a..b6f93b8ef 100644 --- a/osac-metering/metering-service/internal/watch/consumer_test.go +++ b/osac-metering/metering-service/internal/watch/consumer_test.go @@ -810,18 +810,18 @@ var _ = Describe("Consumer", func() { Describe("CaaS Cluster events", func() { makeCluster := func(id, tenant string, state privatev1.ClusterState, nodeSets map[string]*privatev1.ClusterNodeSet) *privatev1.Cluster { - releaseImage := "quay.io/ocp:4.17.0" + versionName := "4.17.0" return &privatev1.Cluster{ Id: id, Metadata: &privatev1.Metadata{ Tenant: tenant, Version: 2, - CreationTimestamp: timestamppb.Now(), + CreationTimestamp: timestamppb.Now(), }, Spec: &privatev1.ClusterSpec{ - Template: "ocp-ci-small", - ReleaseImage: &releaseImage, - NodeSets: nodeSets, + Template: &privatev1.ClusterTemplateReference{Name: "ocp-ci-small"}, + VersionName: &versionName, + NodeSets: nodeSets, }, Status: &privatev1.ClusterStatus{ State: state, @@ -832,15 +832,15 @@ var _ = Describe("Consumer", func() { defaultNodeSets := func() map[string]*privatev1.ClusterNodeSet { return map[string]*privatev1.ClusterNodeSet{ - "gpu-workers": {HostType: "gpu-h100", Size: 2}, - "cpu-workers": {HostType: "cpu-only", Size: 3}, + "gpu-workers": {HostType: &privatev1.HostTypeReference{Name: "gpu-h100"}, Size: 2}, + "cpu-workers": {HostType: &privatev1.HostTypeReference{Name: "cpu-only"}, Size: 3}, } } clusterBillingDims := func() map[string]any { return map[string]any{ "cluster_template": "ocp-ci-small", - "release_image": "quay.io/ocp:4.17.0", + "version_name": "4.17.0", "components": []any{ map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, map[string]any{"node_set": "cpu-workers", "component": "worker", "host_type": "cpu-only", "node_count": int32(3)}, @@ -1083,8 +1083,8 @@ var _ = Describe("Consumer", func() { // Scale gpu-h100 from 2 to 4, cpu-only stays at 3 scaledNodeSets := map[string]*privatev1.ClusterNodeSet{ - "gpu-workers": {HostType: "gpu-h100", Size: 4}, - "cpu-workers": {HostType: "cpu-only", Size: 3}, + "gpu-workers": {HostType: &privatev1.HostTypeReference{Name: "gpu-h100"}, Size: 4}, + "cpu-workers": {HostType: &privatev1.HostTypeReference{Name: "cpu-only"}, Size: 3}, } cl := makeCluster("cl-scale", "tenant-1", privatev1.ClusterState_CLUSTER_STATE_READY, scaledNodeSets) event := &privatev1.Event{ From ce1e5ab9d9a256d94caacc1bf584da598cd372f7 Mon Sep 17 00:00:00 2001 From: omer-vishlitzky Date: Thu, 6 Aug 2026 14:51:35 +0300 Subject: [PATCH 10/18] refactor: replace state machine switches with declarative transition tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit State transitions are now defined as maps — the table IS the spec. Missing entry = error (fail fast). No default branches to get wrong. Adding BMaaS or any new resource type = adding a new table. VMaaS (compute_instance): transient states (STOPPING/STARTING), DELETING as billing-ending, explicit wildcard fallbacks. CaaS (cluster_order): billable-to-billable skip, non-billable skip, same-state transitions for scaling, explicit per-state entries. Shared lookup in transitions.go: exact (from, to) match first, then (*,to) wildcard, then error. Signed-off-by: omer-vishlitzky Assisted-by: Claude Code Signed-off-by: omer-vishlitzky --- .../internal/events/cluster.go | 89 +++++++++------ .../internal/events/cluster_test.go | 108 +++++++++++++++--- .../internal/events/compute_instance.go | 47 ++++---- .../internal/events/mapper.go | 5 +- .../internal/events/mapper_test.go | 12 +- .../internal/events/transitions.go | 61 ++++++++++ 6 files changed, 242 insertions(+), 80 deletions(-) create mode 100644 osac-metering/metering-service/internal/events/transitions.go diff --git a/osac-metering/metering-service/internal/events/cluster.go b/osac-metering/metering-service/internal/events/cluster.go index 3b4c3b72e..16684a40c 100644 --- a/osac-metering/metering-service/internal/events/cluster.go +++ b/osac-metering/metering-service/internal/events/cluster.go @@ -10,7 +10,6 @@ in compliance with the License. You may obtain a copy of the License at package events import ( - "errors" "fmt" "sort" "strings" @@ -19,8 +18,6 @@ import ( privatev1 "github.com/osac-project/osac-metering/internal/api/osac/private/v1" ) -var ErrSkipNonBillingTransition = errors.New("non-billing transition") - const ClusterStatePrefix = "CLUSTER_STATE_" type clusterMapper struct { @@ -85,6 +82,61 @@ func (m *clusterMapper) BillingDimensionsMap() map[string]any { return ClusterBillingDimensions(m.cl) } +// CaaS cluster state machine. Both PROGRESSING and READY are billable. +// Transitions between billable states have no billing boundary (Skip). +// Dimension changes (scaling) during skipped transitions are detected by +// the Watch Consumer via DimensionsEqual; the hourly reconciler catches +// any missed dimension drift. +var clusterTransitions = TransitionTable{ + // Started: first billable state (no previous) + {"", "PROGRESSING"}: {EventType: "osac.resource.started.v1"}, + {"", "READY"}: {EventType: "osac.resource.started.v1"}, + + // Resumed: non-billable to billable + {"FAILED", "PROGRESSING"}: {EventType: "osac.resource.resumed.v1"}, + {"FAILED", "READY"}: {EventType: "osac.resource.resumed.v1"}, + {"DELETING", "PROGRESSING"}: {EventType: "osac.resource.resumed.v1"}, + {"DELETING", "READY"}: {EventType: "osac.resource.resumed.v1"}, + {"DELETE_FAILED", "PROGRESSING"}: {EventType: "osac.resource.resumed.v1"}, + {"DELETE_FAILED", "READY"}: {EventType: "osac.resource.resumed.v1"}, + {"UNSPECIFIED", "PROGRESSING"}: {EventType: "osac.resource.resumed.v1"}, + {"UNSPECIFIED", "READY"}: {EventType: "osac.resource.resumed.v1"}, + + // Suspended: billable to non-billable + {"PROGRESSING", "FAILED"}: {EventType: "osac.resource.suspended.v1"}, + {"PROGRESSING", "DELETING"}: {EventType: "osac.resource.suspended.v1"}, + {"READY", "FAILED"}: {EventType: "osac.resource.suspended.v1"}, + {"READY", "DELETING"}: {EventType: "osac.resource.suspended.v1"}, + + // Skip: billable to billable (no billing boundary, includes same-state for scaling) + {"PROGRESSING", "READY"}: {Skip: true}, + {"READY", "PROGRESSING"}: {Skip: true}, + {"PROGRESSING", "PROGRESSING"}: {Skip: true}, + {"READY", "READY"}: {Skip: true}, + + // Skip: non-billable to non-billable (no billing effect, includes same-state) + {"FAILED", "FAILED"}: {Skip: true}, + {"DELETING", "DELETING"}: {Skip: true}, + {"DELETE_FAILED", "DELETE_FAILED"}: {Skip: true}, + {"UNSPECIFIED", "UNSPECIFIED"}: {Skip: true}, + {"FAILED", "DELETING"}: {Skip: true}, + {"FAILED", "DELETE_FAILED"}: {Skip: true}, + {"DELETING", "DELETE_FAILED"}: {Skip: true}, + {"DELETE_FAILED", "DELETING"}: {Skip: true}, + {"DELETING", "FAILED"}: {Skip: true}, + {"DELETE_FAILED", "FAILED"}: {Skip: true}, + {"UNSPECIFIED", "FAILED"}: {Skip: true}, + {"UNSPECIFIED", "DELETING"}: {Skip: true}, + {"UNSPECIFIED", "DELETE_FAILED"}: {Skip: true}, + {"FAILED", "UNSPECIFIED"}: {Skip: true}, + {"DELETING", "UNSPECIFIED"}: {Skip: true}, + {"DELETE_FAILED", "UNSPECIFIED"}: {Skip: true}, + {"PROGRESSING", "UNSPECIFIED"}: {EventType: "osac.resource.suspended.v1"}, + {"READY", "UNSPECIFIED"}: {EventType: "osac.resource.suspended.v1"}, + {"READY", "DELETE_FAILED"}: {EventType: "osac.resource.suspended.v1"}, + {"PROGRESSING", "DELETE_FAILED"}: {EventType: "osac.resource.suspended.v1"}, +} + func (m *clusterMapper) CloudEventType(eventType privatev1.EventType, previousState string) (string, error) { switch eventType { case privatev1.EventType_EVENT_TYPE_OBJECT_CREATED: @@ -92,41 +144,12 @@ func (m *clusterMapper) CloudEventType(eventType privatev1.EventType, previousSt case privatev1.EventType_EVENT_TYPE_OBJECT_DELETED: return "osac.resource.deleted.v1", nil case privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED: - return m.resolveUpdatedEventType(previousState) + return resolveTransition(clusterTransitions, previousState, m.CurrentState()) default: return "", fmt.Errorf("unsupported event type: %v", eventType) } } -// CaaS billing model: both PROGRESSING and READY are billable. Transitions -// between them have no billing boundary — the interval continues. Dimension -// changes (scaling) during such transitions are detected by the Watch Consumer -// after receiving ErrSkipNonBillingTransition; it checks DimensionsEqual and -// emits updated.v1 for changed components. If the consumer misses a dimension -// change during PROGRESSING<->READY (unlikely — scaling during provisioning), -// the hourly reconciler detects billing_dimensions_drift and emits a -// correction event, bounding the gap to one reconciliation cycle. -func (m *clusterMapper) resolveUpdatedEventType(previousState string) (string, error) { - currentState := m.CurrentState() - currentBillable := IsClusterBillableState(currentState) - previousBillable := IsClusterBillableState(previousState) - - switch { - case currentBillable && previousState == "": - return "osac.resource.started.v1", nil - case currentBillable && !previousBillable: - return "osac.resource.resumed.v1", nil - case !currentBillable && previousBillable: - return "osac.resource.suspended.v1", nil - case currentBillable && previousBillable: - return "", ErrSkipNonBillingTransition - case !currentBillable && !previousBillable: - return "", ErrSkipNonBillingTransition - default: - return "", fmt.Errorf("unexpected cluster state transition: %s -> %s", previousState, currentState) - } -} - func (m *clusterMapper) TransitionTime(eventType privatev1.EventType) (time.Time, error) { switch eventType { case privatev1.EventType_EVENT_TYPE_OBJECT_CREATED: diff --git a/osac-metering/metering-service/internal/events/cluster_test.go b/osac-metering/metering-service/internal/events/cluster_test.go index 9a56b2782..866fe1ea6 100644 --- a/osac-metering/metering-service/internal/events/cluster_test.go +++ b/osac-metering/metering-service/internal/events/cluster_test.go @@ -64,36 +64,110 @@ var _ = Describe("CaaS Cluster Mapper", func() { Expect(ce.Type()).To(Equal(expectedType)) } }, + // --- Started: first billable state (no previous) --- Entry("initial PROGRESSING (prev=empty) -> started.v1", privatev1.ClusterState_CLUSTER_STATE_PROGRESSING, "", "osac.resource.started.v1", false), Entry("initial READY (prev=empty) -> started.v1", privatev1.ClusterState_CLUSTER_STATE_READY, "", "osac.resource.started.v1", false), - Entry("PROGRESSING -> READY -> skip (both billable)", - privatev1.ClusterState_CLUSTER_STATE_READY, "PROGRESSING", "", true), - Entry("READY -> PROGRESSING -> skip (both billable)", - privatev1.ClusterState_CLUSTER_STATE_PROGRESSING, "READY", "", true), - Entry("READY -> FAILED -> suspended.v1", - privatev1.ClusterState_CLUSTER_STATE_FAILED, "READY", "osac.resource.suspended.v1", false), - Entry("PROGRESSING -> FAILED -> suspended.v1", - privatev1.ClusterState_CLUSTER_STATE_FAILED, "PROGRESSING", "osac.resource.suspended.v1", false), + + // --- Resumed: non-billable to billable --- Entry("FAILED -> PROGRESSING -> resumed.v1", privatev1.ClusterState_CLUSTER_STATE_PROGRESSING, "FAILED", "osac.resource.resumed.v1", false), - Entry("FAILED -> READY -> resumed.v1 (recovery direct to ready)", + Entry("FAILED -> READY -> resumed.v1", privatev1.ClusterState_CLUSTER_STATE_READY, "FAILED", "osac.resource.resumed.v1", false), - Entry("READY -> DELETING -> suspended.v1", - privatev1.ClusterState_CLUSTER_STATE_DELETING, "READY", "osac.resource.suspended.v1", false), + Entry("DELETING -> PROGRESSING -> resumed.v1", + privatev1.ClusterState_CLUSTER_STATE_PROGRESSING, "DELETING", "osac.resource.resumed.v1", false), + Entry("DELETING -> READY -> resumed.v1", + privatev1.ClusterState_CLUSTER_STATE_READY, "DELETING", "osac.resource.resumed.v1", false), + Entry("DELETE_FAILED -> PROGRESSING -> resumed.v1", + privatev1.ClusterState_CLUSTER_STATE_PROGRESSING, "DELETE_FAILED", "osac.resource.resumed.v1", false), + Entry("DELETE_FAILED -> READY -> resumed.v1", + privatev1.ClusterState_CLUSTER_STATE_READY, "DELETE_FAILED", "osac.resource.resumed.v1", false), + Entry("UNSPECIFIED -> PROGRESSING -> resumed.v1", + privatev1.ClusterState_CLUSTER_STATE_PROGRESSING, "UNSPECIFIED", "osac.resource.resumed.v1", false), + Entry("UNSPECIFIED -> READY -> resumed.v1", + privatev1.ClusterState_CLUSTER_STATE_READY, "UNSPECIFIED", "osac.resource.resumed.v1", false), + + // --- Suspended: billable to non-billable --- + Entry("PROGRESSING -> FAILED -> suspended.v1", + privatev1.ClusterState_CLUSTER_STATE_FAILED, "PROGRESSING", "osac.resource.suspended.v1", false), Entry("PROGRESSING -> DELETING -> suspended.v1", privatev1.ClusterState_CLUSTER_STATE_DELETING, "PROGRESSING", "osac.resource.suspended.v1", false), - Entry("DELETING -> DELETE_FAILED -> skip (both non-billable)", - privatev1.ClusterState_CLUSTER_STATE_DELETE_FAILED, "DELETING", "", true), - Entry("DELETE_FAILED -> DELETING -> skip (both non-billable)", - privatev1.ClusterState_CLUSTER_STATE_DELETING, "DELETE_FAILED", "", true), - Entry("FAILED -> DELETING -> skip (both non-billable)", + Entry("READY -> FAILED -> suspended.v1", + privatev1.ClusterState_CLUSTER_STATE_FAILED, "READY", "osac.resource.suspended.v1", false), + Entry("READY -> DELETING -> suspended.v1", + privatev1.ClusterState_CLUSTER_STATE_DELETING, "READY", "osac.resource.suspended.v1", false), + Entry("PROGRESSING -> UNSPECIFIED -> suspended.v1", + privatev1.ClusterState_CLUSTER_STATE_UNSPECIFIED, "PROGRESSING", "osac.resource.suspended.v1", false), + Entry("READY -> UNSPECIFIED -> suspended.v1", + privatev1.ClusterState_CLUSTER_STATE_UNSPECIFIED, "READY", "osac.resource.suspended.v1", false), + Entry("READY -> DELETE_FAILED -> suspended.v1", + privatev1.ClusterState_CLUSTER_STATE_DELETE_FAILED, "READY", "osac.resource.suspended.v1", false), + Entry("PROGRESSING -> DELETE_FAILED -> suspended.v1", + privatev1.ClusterState_CLUSTER_STATE_DELETE_FAILED, "PROGRESSING", "osac.resource.suspended.v1", false), + + // --- Skip: billable to billable (no billing boundary) --- + Entry("PROGRESSING -> READY -> skip", + privatev1.ClusterState_CLUSTER_STATE_READY, "PROGRESSING", "", true), + Entry("READY -> PROGRESSING -> skip", + privatev1.ClusterState_CLUSTER_STATE_PROGRESSING, "READY", "", true), + Entry("PROGRESSING -> PROGRESSING -> skip (same-state)", + privatev1.ClusterState_CLUSTER_STATE_PROGRESSING, "PROGRESSING", "", true), + Entry("READY -> READY -> skip (same-state, scaling)", + privatev1.ClusterState_CLUSTER_STATE_READY, "READY", "", true), + + // --- Skip: non-billable same-state --- + Entry("FAILED -> FAILED -> skip (same-state)", + privatev1.ClusterState_CLUSTER_STATE_FAILED, "FAILED", "", true), + Entry("DELETING -> DELETING -> skip (same-state)", + privatev1.ClusterState_CLUSTER_STATE_DELETING, "DELETING", "", true), + Entry("DELETE_FAILED -> DELETE_FAILED -> skip (same-state)", + privatev1.ClusterState_CLUSTER_STATE_DELETE_FAILED, "DELETE_FAILED", "", true), + Entry("UNSPECIFIED -> UNSPECIFIED -> skip (same-state)", + privatev1.ClusterState_CLUSTER_STATE_UNSPECIFIED, "UNSPECIFIED", "", true), + + // --- Skip: non-billable to non-billable (cross-state) --- + Entry("FAILED -> DELETING -> skip", privatev1.ClusterState_CLUSTER_STATE_DELETING, "FAILED", "", true), - Entry("FAILED -> DELETE_FAILED -> skip (both non-billable)", + Entry("FAILED -> DELETE_FAILED -> skip", privatev1.ClusterState_CLUSTER_STATE_DELETE_FAILED, "FAILED", "", true), + Entry("DELETING -> DELETE_FAILED -> skip", + privatev1.ClusterState_CLUSTER_STATE_DELETE_FAILED, "DELETING", "", true), + Entry("DELETE_FAILED -> DELETING -> skip", + privatev1.ClusterState_CLUSTER_STATE_DELETING, "DELETE_FAILED", "", true), + Entry("DELETING -> FAILED -> skip", + privatev1.ClusterState_CLUSTER_STATE_FAILED, "DELETING", "", true), + Entry("DELETE_FAILED -> FAILED -> skip", + privatev1.ClusterState_CLUSTER_STATE_FAILED, "DELETE_FAILED", "", true), + Entry("UNSPECIFIED -> FAILED -> skip", + privatev1.ClusterState_CLUSTER_STATE_FAILED, "UNSPECIFIED", "", true), + Entry("UNSPECIFIED -> DELETING -> skip", + privatev1.ClusterState_CLUSTER_STATE_DELETING, "UNSPECIFIED", "", true), + Entry("UNSPECIFIED -> DELETE_FAILED -> skip", + privatev1.ClusterState_CLUSTER_STATE_DELETE_FAILED, "UNSPECIFIED", "", true), + Entry("FAILED -> UNSPECIFIED -> skip", + privatev1.ClusterState_CLUSTER_STATE_UNSPECIFIED, "FAILED", "", true), + Entry("DELETING -> UNSPECIFIED -> skip", + privatev1.ClusterState_CLUSTER_STATE_UNSPECIFIED, "DELETING", "", true), + Entry("DELETE_FAILED -> UNSPECIFIED -> skip", + privatev1.ClusterState_CLUSTER_STATE_UNSPECIFIED, "DELETE_FAILED", "", true), ) + It("returns error for unknown state (missing table entry)", func() { + cl.Status.State = privatev1.ClusterState(9999) + + event := &privatev1.Event{ + Id: "evt-unknown", + Type: privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, + Payload: &privatev1.Event_Cluster{Cluster: cl}, + } + + _, err := mapEvent(event, &events.StateContext{PreviousState: "READY"}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("unexpected state transition")) + Expect(errors.Is(err, events.ErrSkipNonBillingTransition)).To(BeFalse()) + }) + It("maps OBJECT_CREATED to created.v1", func() { event := &privatev1.Event{ Id: "evt-create", diff --git a/osac-metering/metering-service/internal/events/compute_instance.go b/osac-metering/metering-service/internal/events/compute_instance.go index 810053fea..2bf3c3ee6 100644 --- a/osac-metering/metering-service/internal/events/compute_instance.go +++ b/osac-metering/metering-service/internal/events/compute_instance.go @@ -10,6 +10,32 @@ import ( const ComputeInstanceStatePrefix = "COMPUTE_INSTANCE_STATE_" +// Compute instance state machine. The table IS the spec: +// - Exact (from, to) match takes priority over wildcard +// - Missing entry = error (fail fast) +// - Transient: projection-only, no CloudEvent (billing context preserved) +var computeInstanceTransitions = TransitionTable{ + // Resumptions: specific previous states take priority over wildcard + {"STOPPED", "RUNNING"}: {EventType: "osac.resource.resumed.v1"}, + {"PAUSED", "RUNNING"}: {EventType: "osac.resource.resumed.v1"}, + + // Started: any other previous state transitioning to RUNNING + {"*", "RUNNING"}: {EventType: "osac.resource.started.v1"}, + + // Suspended: billing interval closes + {"*", "STOPPED"}: {EventType: "osac.resource.suspended.v1"}, + {"*", "PAUSED"}: {EventType: "osac.resource.suspended.v1"}, + {"*", "FAILED"}: {EventType: "osac.resource.suspended.v1"}, + {"*", "DELETING"}: {EventType: "osac.resource.suspended.v1"}, + + // Transient: intermediate states, preserve billing context + {"*", "STOPPING"}: {Transient: true}, + {"*", "STARTING"}: {Transient: true}, + + // Updated: no billing effect + {"*", "UNSPECIFIED"}: {EventType: "osac.resource.updated.v1"}, +} + type computeInstanceMapper struct { ci *privatev1.ComputeInstance } @@ -107,31 +133,12 @@ func (m *computeInstanceMapper) CloudEventType(eventType privatev1.EventType, pr case privatev1.EventType_EVENT_TYPE_OBJECT_DELETED: return "osac.resource.deleted.v1", nil case privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED: - return m.resolveUpdatedEventType(previousState) + return resolveTransition(computeInstanceTransitions, previousState, m.CurrentState()) default: return "", fmt.Errorf("unsupported event type: %v", eventType) } } -func (m *computeInstanceMapper) resolveUpdatedEventType(previousState string) (string, error) { - currentState := m.CurrentState() - - switch { - case currentState == "RUNNING" && (previousState == "STOPPED" || previousState == "PAUSED"): - return "osac.resource.resumed.v1", nil - case currentState == "RUNNING": - return "osac.resource.started.v1", nil - case currentState == "STOPPED" || currentState == "PAUSED" || currentState == "FAILED" || currentState == "DELETING": - return "osac.resource.suspended.v1", nil - case currentState == "STOPPING" || currentState == "STARTING": - return "", ErrTransientState - case currentState == "UNSPECIFIED": - return "osac.resource.updated.v1", nil - default: - return "", fmt.Errorf("unexpected compute instance state transition: %s -> %s", previousState, currentState) - } -} - func (m *computeInstanceMapper) TransitionTime(eventType privatev1.EventType) (time.Time, error) { switch eventType { case privatev1.EventType_EVENT_TYPE_OBJECT_CREATED: diff --git a/osac-metering/metering-service/internal/events/mapper.go b/osac-metering/metering-service/internal/events/mapper.go index c0c34ecf2..dcd188b75 100644 --- a/osac-metering/metering-service/internal/events/mapper.go +++ b/osac-metering/metering-service/internal/events/mapper.go @@ -10,10 +10,7 @@ import ( privatev1 "github.com/osac-project/osac-metering/internal/api/osac/private/v1" ) -var ( - ErrDataQuality = errors.New("data quality") - ErrTransientState = errors.New("transient state: update projection only, no CloudEvent") -) +var ErrDataQuality = errors.New("data quality") // ResourceMapper extracts metering data from a resource-specific Event payload. // Each OSAC resource type (ComputeInstance, ClusterOrder, etc.) implements this. diff --git a/osac-metering/metering-service/internal/events/mapper_test.go b/osac-metering/metering-service/internal/events/mapper_test.go index 0ad97d63c..0c71f3809 100644 --- a/osac-metering/metering-service/internal/events/mapper_test.go +++ b/osac-metering/metering-service/internal/events/mapper_test.go @@ -124,8 +124,8 @@ var _ = Describe("MapWatchEvent", func() { Expect(ce.Type()).To(Equal("osac.resource.suspended.v1")) }) - It("returns ErrTransientState for STOPPING state", func() { - ci.Status.State = privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STOPPING + It("returns ErrTransientState for STARTING state", func() { + ci.Status.State = privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STARTING event := &privatev1.Event{ Id: "evt-2", @@ -138,11 +138,11 @@ var _ = Describe("MapWatchEvent", func() { Expect(errors.Is(err, events.ErrTransientState)).To(BeTrue()) }) - It("returns ErrTransientState for STARTING state", func() { - ci.Status.State = privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STARTING + It("returns ErrTransientState for STOPPING state", func() { + ci.Status.State = privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STOPPING event := &privatev1.Event{ - Id: "evt-starting", + Id: "evt-2", Type: privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, Payload: &privatev1.Event_ComputeInstance{ComputeInstance: ci}, } @@ -267,7 +267,7 @@ var _ = Describe("MapWatchEvent", func() { _, err := mapEvent(event, &events.StateContext{}) Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("unexpected compute instance state transition")) + Expect(err.Error()).To(ContainSubstring("unexpected state transition")) Expect(errors.Is(err, events.ErrTransientState)).To(BeFalse()) }) diff --git a/osac-metering/metering-service/internal/events/transitions.go b/osac-metering/metering-service/internal/events/transitions.go new file mode 100644 index 000000000..a08f23b0b --- /dev/null +++ b/osac-metering/metering-service/internal/events/transitions.go @@ -0,0 +1,61 @@ +/* +Copyright (c) 2026 Red Hat, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ + +package events + +import ( + "errors" + "fmt" +) + +var ( + ErrTransientState = errors.New("transient state: update projection only, no CloudEvent") + ErrSkipNonBillingTransition = errors.New("non-billing transition") +) + +// TransitionKey identifies a state transition by (previous, current) state. +// Use "*" as From to match any previous state (wildcard). +type TransitionKey struct { + From string + To string +} + +// TransitionResult defines what happens on a state transition. +type TransitionResult struct { + EventType string // CloudEvent type to emit (empty when Transient or Skip) + Transient bool // projection-only update, no CloudEvent + Skip bool // no projection update, no CloudEvent (non-billing transition) +} + +// TransitionTable maps (previous, current) state pairs to their billing effect. +// Missing entries are invalid transitions — resolveTransition returns an error. +type TransitionTable map[TransitionKey]TransitionResult + +// resolveTransition looks up the event type for a state transition. +// Exact (from, to) match takes priority over wildcard (*, to). +// Missing entry = error (fail fast on unknown transitions). +func resolveTransition(table TransitionTable, from, to string) (string, error) { + if result, ok := table[TransitionKey{from, to}]; ok { + return applyResult(result) + } + if result, ok := table[TransitionKey{"*", to}]; ok { + return applyResult(result) + } + return "", fmt.Errorf("unexpected state transition: %s -> %s", from, to) +} + +func applyResult(r TransitionResult) (string, error) { + if r.Skip { + return "", ErrSkipNonBillingTransition + } + if r.Transient { + return "", ErrTransientState + } + return r.EventType, nil +} From 78bd67430771a5c290a4cdd5b8baee6e17a8887b Mon Sep 17 00:00:00 2001 From: omer-vishlitzky Date: Thu, 6 Aug 2026 15:29:54 +0300 Subject: [PATCH 11/18] fix: publish-first ordering, missing transitions, exhaustive table tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes review findings from 5-agent critical review: 1. publishAndUpsert: all create/update paths now publish events BEFORE committing projection state. If Kafka fails, projection is not committed, replay retries full publish. Prevents permanent event loss on partial N+1 publish failure. Delete path was already correct (publish-first from PR 135 review). 2. Missing initial CaaS transitions: added ""->FAILED/DELETING/ DELETE_FAILED/UNSPECIFIED as Skip. Prevents consumer crash when first-observed cluster is in non-billable state (bootstrap, reconnect after failure, existing deployment). 3. ErrSkipNonBillingTransition renamed to ErrSkipTransition — the old name was misleading for billable-to-billable transitions (PROGRESSING->READY). 4. Strengthened multi-removal test: verifies exact NodeSet->HostType preservation, not just non-empty NodeSet. 5. DimensionsEqual component ordering test: documents that array order matters (ClusterBillingDimensions sorts keys for determinism). 6. Proto enumeration completeness test: iterates every (from, to) state pair from all ClusterState proto values plus empty initial. If someone removes a table entry, this test fails — prevents wildcard masking. 7. Stale version test updated: publish-first means events reach Kafka even when projection upsert is skipped (adapter dedup handles it). Signed-off-by: omer-vishlitzky Assisted-by: Claude Code Signed-off-by: omer-vishlitzky --- .../internal/events/cluster.go | 6 ++ .../internal/events/cluster_test.go | 87 +++++++++++++++++-- .../internal/events/transitions.go | 6 +- .../internal/watch/consumer.go | 62 ++++++------- .../internal/watch/consumer_test.go | 14 ++- 5 files changed, 126 insertions(+), 49 deletions(-) diff --git a/osac-metering/metering-service/internal/events/cluster.go b/osac-metering/metering-service/internal/events/cluster.go index 16684a40c..6a7bbaca7 100644 --- a/osac-metering/metering-service/internal/events/cluster.go +++ b/osac-metering/metering-service/internal/events/cluster.go @@ -92,6 +92,12 @@ var clusterTransitions = TransitionTable{ {"", "PROGRESSING"}: {EventType: "osac.resource.started.v1"}, {"", "READY"}: {EventType: "osac.resource.started.v1"}, + // Skip: first observed in non-billable state (bootstrap, reconnect after failure) + {"", "FAILED"}: {Skip: true}, + {"", "DELETING"}: {Skip: true}, + {"", "DELETE_FAILED"}: {Skip: true}, + {"", "UNSPECIFIED"}: {Skip: true}, + // Resumed: non-billable to billable {"FAILED", "PROGRESSING"}: {EventType: "osac.resource.resumed.v1"}, {"FAILED", "READY"}: {EventType: "osac.resource.resumed.v1"}, diff --git a/osac-metering/metering-service/internal/events/cluster_test.go b/osac-metering/metering-service/internal/events/cluster_test.go index 866fe1ea6..9c5841cd7 100644 --- a/osac-metering/metering-service/internal/events/cluster_test.go +++ b/osac-metering/metering-service/internal/events/cluster_test.go @@ -58,7 +58,7 @@ var _ = Describe("CaaS Cluster Mapper", func() { if expectSkip { Expect(err).To(HaveOccurred()) - Expect(errors.Is(err, events.ErrSkipNonBillingTransition)).To(BeTrue()) + Expect(errors.Is(err, events.ErrSkipTransition)).To(BeTrue()) } else { Expect(err).NotTo(HaveOccurred()) Expect(ce.Type()).To(Equal(expectedType)) @@ -70,6 +70,16 @@ var _ = Describe("CaaS Cluster Mapper", func() { Entry("initial READY (prev=empty) -> started.v1", privatev1.ClusterState_CLUSTER_STATE_READY, "", "osac.resource.started.v1", false), + // --- Skip: first observed in non-billable state --- + Entry("initial FAILED (prev=empty) -> skip", + privatev1.ClusterState_CLUSTER_STATE_FAILED, "", "", true), + Entry("initial DELETING (prev=empty) -> skip", + privatev1.ClusterState_CLUSTER_STATE_DELETING, "", "", true), + Entry("initial DELETE_FAILED (prev=empty) -> skip", + privatev1.ClusterState_CLUSTER_STATE_DELETE_FAILED, "", "", true), + Entry("initial UNSPECIFIED (prev=empty) -> skip", + privatev1.ClusterState_CLUSTER_STATE_UNSPECIFIED, "", "", true), + // --- Resumed: non-billable to billable --- Entry("FAILED -> PROGRESSING -> resumed.v1", privatev1.ClusterState_CLUSTER_STATE_PROGRESSING, "FAILED", "osac.resource.resumed.v1", false), @@ -165,7 +175,7 @@ var _ = Describe("CaaS Cluster Mapper", func() { _, err := mapEvent(event, &events.StateContext{PreviousState: "READY"}) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("unexpected state transition")) - Expect(errors.Is(err, events.ErrSkipNonBillingTransition)).To(BeFalse()) + Expect(errors.Is(err, events.ErrSkipTransition)).To(BeFalse()) }) It("maps OBJECT_CREATED to created.v1", func() { @@ -641,17 +651,17 @@ var _ = Describe("ChangedComponents", func() { changed := events.ChangedComponents(oldDims, newDims) Expect(changed).To(HaveLen(2)) - nodeSets := map[string]bool{} + nodeSetToHostType := map[string]string{} for _, c := range changed { Expect(c.NodeCount).To(Equal(int32(0))) Expect(c.NodeSet).NotTo(BeEmpty()) id := events.ComponentEventID("evt-1", c) Expect(id).NotTo(Equal("evt-1/")) - nodeSets[c.NodeSet] = true + nodeSetToHostType[c.NodeSet] = c.HostType } - Expect(nodeSets).To(HaveLen(2)) - Expect(nodeSets).To(HaveKey("pool-a")) - Expect(nodeSets).To(HaveKey("pool-b")) + Expect(nodeSetToHostType).To(HaveLen(2)) + Expect(nodeSetToHostType["pool-a"]).To(Equal("gpu-h100")) + Expect(nodeSetToHostType["pool-b"]).To(Equal("cpu-only")) }) It("handles int32 vs float64 from JSONB round-trip", func() { @@ -811,4 +821,67 @@ var _ = Describe("DimensionsEqual with nested CaaS components", func() { } Expect(events.DimensionsEqual(a, b)).To(BeFalse()) }) + + It("treats different component array order as unequal", func() { + a := map[string]any{ + "cluster_template": "tmpl", + "components": []any{ + map[string]any{"node_set": "_control_plane", "node_count": int32(1)}, + map[string]any{"node_set": "gpu-workers", "node_count": int32(2)}, + }, + } + b := map[string]any{ + "cluster_template": "tmpl", + "components": []any{ + map[string]any{"node_set": "gpu-workers", "node_count": int32(2)}, + map[string]any{"node_set": "_control_plane", "node_count": int32(1)}, + }, + } + Expect(events.DimensionsEqual(a, b)).To(BeFalse(), + "component array order matters — ClusterBillingDimensions sorts keys for deterministic ordering") + }) +}) + +var _ = Describe("CaaS transition table completeness", func() { + stateProtoMap := map[string]privatev1.ClusterState{ + "PROGRESSING": privatev1.ClusterState_CLUSTER_STATE_PROGRESSING, + "READY": privatev1.ClusterState_CLUSTER_STATE_READY, + "FAILED": privatev1.ClusterState_CLUSTER_STATE_FAILED, + "DELETING": privatev1.ClusterState_CLUSTER_STATE_DELETING, + "DELETE_FAILED": privatev1.ClusterState_CLUSTER_STATE_DELETE_FAILED, + "UNSPECIFIED": privatev1.ClusterState_CLUSTER_STATE_UNSPECIFIED, + } + + It("covers every (from, to) state pair from all proto states plus empty initial", func() { + fromStates := []string{"", "PROGRESSING", "READY", "FAILED", "DELETING", "DELETE_FAILED", "UNSPECIFIED"} + toStates := []string{"PROGRESSING", "READY", "FAILED", "DELETING", "DELETE_FAILED", "UNSPECIFIED"} + + for _, from := range fromStates { + for _, to := range toStates { + cl := &privatev1.Cluster{ + Id: "cl-completeness", + Metadata: &privatev1.Metadata{Tenant: "t", CreationTimestamp: timestamppb.Now()}, + Spec: &privatev1.ClusterSpec{}, + Status: &privatev1.ClusterStatus{ + State: stateProtoMap[to], + StateTransitionTime: timestamppb.Now(), + }, + } + + event := &privatev1.Event{ + Id: "evt-completeness", + Type: privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, + Payload: &privatev1.Event_Cluster{Cluster: cl}, + } + + stateCtx := &events.StateContext{PreviousState: from} + _, err := mapEvent(event, stateCtx) + + Expect(err == nil || + errors.Is(err, events.ErrSkipTransition) || + errors.Is(err, events.ErrTransientState)).To(BeTrue(), + "transition %s -> %s returned unexpected error: %v", from, to, err) + } + } + }) }) diff --git a/osac-metering/metering-service/internal/events/transitions.go b/osac-metering/metering-service/internal/events/transitions.go index a08f23b0b..64745d850 100644 --- a/osac-metering/metering-service/internal/events/transitions.go +++ b/osac-metering/metering-service/internal/events/transitions.go @@ -15,8 +15,8 @@ import ( ) var ( - ErrTransientState = errors.New("transient state: update projection only, no CloudEvent") - ErrSkipNonBillingTransition = errors.New("non-billing transition") + ErrTransientState = errors.New("transient state: update projection only, no CloudEvent") + ErrSkipTransition = errors.New("no billing boundary: skip event, check for scaling") ) // TransitionKey identifies a state transition by (previous, current) state. @@ -52,7 +52,7 @@ func resolveTransition(table TransitionTable, from, to string) (string, error) { func applyResult(r TransitionResult) (string, error) { if r.Skip { - return "", ErrSkipNonBillingTransition + return "", ErrSkipTransition } if r.Transient { return "", ErrTransientState diff --git a/osac-metering/metering-service/internal/watch/consumer.go b/osac-metering/metering-service/internal/watch/consumer.go index b4d70d7e3..38f0103c7 100644 --- a/osac-metering/metering-service/internal/watch/consumer.go +++ b/osac-metering/metering-service/internal/watch/consumer.go @@ -153,7 +153,7 @@ func (c *Consumer) handleEvent(ctx context.Context, event *privatev1.Event) erro if errors.Is(err, events.ErrTransientState) { return c.handleTransientState(ctx, mapper, existing, version, transitionTime) } - if errors.Is(err, events.ErrSkipNonBillingTransition) { + if errors.Is(err, events.ErrSkipTransition) { if existing != nil && !events.DimensionsEqual(existing.BillingDimensions, dims) { return c.handleScalingEvent(ctx, event, mapper, existing, transitionTime, version, currentState, isBillable, dims) } @@ -182,20 +182,29 @@ func (c *Consumer) handleEvent(ctx context.Context, event *privatev1.Event) erro return nil } - err = c.store.Upsert(ctx, projState) - if err != nil { + return c.publishAndUpsert(ctx, func() error { + return c.publishLifecycleEvents(ctx, ce, mapper, event.GetId()) + }, projState, resourceID) +} + +// publishAndUpsert publishes events first, then commits projection state. +// Publish-first ensures no data loss: if publish fails, projection is not +// committed, and replay retries the full publish. If upsert fails after +// successful publish, replay produces duplicate events (handled by adapter +// dedup via deterministic CloudEvent IDs). +func (c *Consumer) publishAndUpsert(ctx context.Context, publish func() error, state projection.ResourceState, resourceID string) error { + if err := publish(); err != nil { + return err + } + + if err := c.store.Upsert(ctx, state); err != nil { if errors.Is(err, projection.ErrStaleVersion) { c.logger.Info("stale version, skipping projection update", - "resource_id", resourceID, "version", version) + "resource_id", resourceID) return nil } return fmt.Errorf("upserting projection for %s: %w", resourceID, err) } - - if err := c.publishLifecycleEvents(ctx, ce, mapper, event.GetId()); err != nil { - return err - } - return nil } @@ -260,36 +269,29 @@ func (c *Consumer) publishLifecycleEvents(ctx context.Context, baseCE *cloudeven func (c *Consumer) handleScalingEvent(ctx context.Context, event *privatev1.Event, mapper events.ResourceMapper, existing *projection.ResourceState, transitionTime time.Time, version int32, currentState string, isBillable bool, dims map[string]any) error { resourceID := mapper.ResourceID() changed := events.ChangedComponents(existing.BillingDimensions, dims) - projState := c.buildProjectionState(mapper, existing, transitionTime, version, currentState, isBillable, dims) - if err := c.store.Upsert(ctx, projState); err != nil { - if errors.Is(err, projection.ErrStaleVersion) { - c.logger.Info("stale version during scaling, skipping", "resource_id", resourceID) - return nil - } - return fmt.Errorf("upserting projection for scaling %s: %w", resourceID, err) - } if len(changed) == 0 { c.logger.V(1).Info("non-component dimension change, projection updated", "resource_id", resourceID) - return nil + return c.publishAndUpsert(ctx, func() error { return nil }, projState, resourceID) } stateCtx := c.buildStateContext(existing, isBillable, transitionTime, dims) - for _, comp := range changed { - ce, ceErr := c.buildScalingEvent(event.GetId(), mapper, comp, stateCtx, transitionTime) - if ceErr != nil { - return ceErr - } - if err := c.publishWithRetry(ctx, &ce); err != nil { - return err + return c.publishAndUpsert(ctx, func() error { + for _, comp := range changed { + ce, ceErr := c.buildScalingEvent(event.GetId(), mapper, comp, stateCtx, transitionTime) + if ceErr != nil { + return ceErr + } + if err := c.publishWithRetry(ctx, &ce); err != nil { + return err + } } - } - - c.logger.Info("published scaling events", - "resource_id", resourceID, "changed_components", len(changed)) - return nil + c.logger.Info("published scaling events", + "resource_id", resourceID, "changed_components", len(changed)) + return nil + }, projState, resourceID) } func (c *Consumer) buildComponentEvent(baseCE *cloudevents.Event, eventID string, comp events.ComponentRecord) (cloudevents.Event, error) { diff --git a/osac-metering/metering-service/internal/watch/consumer_test.go b/osac-metering/metering-service/internal/watch/consumer_test.go index b6f93b8ef..ab9a27cb7 100644 --- a/osac-metering/metering-service/internal/watch/consumer_test.go +++ b/osac-metering/metering-service/internal/watch/consumer_test.go @@ -607,7 +607,7 @@ var _ = Describe("Consumer", func() { Expect(store.states).ToNot(HaveKey("vm-del")) }) - It("skips projection update on stale version and does not publish", func() { + It("publishes event but skips projection update on stale version", func() { store := newMockStore() now := time.Now().UTC().Truncate(time.Microsecond) store.states["vm-stale"] = projection.ResourceState{ @@ -634,19 +634,15 @@ var _ = Describe("Consumer", func() { } client.results = []mockStreamResult{{stream: stream}} - pub := &mockPublisher{} + pub := &mockPublisher{published: make([]cloudevents.Event, 0, 1), cancelFunc: cancel} consumer := newConsumerWithStore(pub, store) - done := make(chan error, 1) - go func() { done <- consumer.Run(ctx) }() - - time.Sleep(50 * time.Millisecond) - cancel() - Eventually(done, time.Second).Should(Receive(BeNil())) + err := consumer.Run(ctx) + Expect(err).ToNot(HaveOccurred()) pub.mu.Lock() defer pub.mu.Unlock() - Expect(pub.published).To(BeEmpty()) + Expect(pub.published).To(HaveLen(1), "event published even when projection is stale") store.mu.Lock() defer store.mu.Unlock() From 20f49bf0c0382eac67b3ae22dc51c25240a2b291 Mon Sep 17 00:00:00 2001 From: omer-vishlitzky Date: Thu, 6 Aug 2026 15:51:29 +0300 Subject: [PATCH 12/18] refactor: extract DecomposeClusterEvents shared helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses amito's review comment: the N+1 decompose-build-publish pattern repeated across publishLifecycleEvents, buildCorrectionEvents, buildSyntheticHeartbeats, and buildHeartbeatEvents. DecomposeClusterEvents handles only cluster_order — no fallback. Empty components returns ErrDataQuality (fail fast, not silent single-event degradation). Callers branch on resource type and call DecomposeClusterEvents only for cluster_order. Signed-off-by: omer-vishlitzky Assisted-by: Claude Code Signed-off-by: omer-vishlitzky --- .../internal/events/cluster.go | 22 ++++++ .../internal/events/cluster_test.go | 69 +++++++++++++++++++ .../internal/heartbeat/generator.go | 34 +++------ .../internal/reconciliation/correction.go | 29 ++++---- .../internal/reconciliation/reconciler.go | 19 ++--- .../internal/watch/consumer.go | 35 +++++----- 6 files changed, 134 insertions(+), 74 deletions(-) diff --git a/osac-metering/metering-service/internal/events/cluster.go b/osac-metering/metering-service/internal/events/cluster.go index 6a7bbaca7..94d48342d 100644 --- a/osac-metering/metering-service/internal/events/cluster.go +++ b/osac-metering/metering-service/internal/events/cluster.go @@ -15,6 +15,8 @@ import ( "strings" "time" + cloudevents "github.com/cloudevents/sdk-go/v2" + privatev1 "github.com/osac-project/osac-metering/internal/api/osac/private/v1" ) @@ -316,6 +318,26 @@ func ComponentEventID(baseEventID string, comp ComponentRecord) string { return fmt.Sprintf("%s/%s", baseEventID, comp.NodeSet) } +// DecomposeClusterEvents fans out a single event into N+1 per-component events. +// Returns error if billing dimensions have no components (data quality issue). +// buildFn receives (per-component billing dimensions, deterministic event ID). +func DecomposeClusterEvents(billingDims map[string]any, baseID string, buildFn func(dims map[string]any, eventID string) (cloudevents.Event, error)) ([]cloudevents.Event, error) { + components := DecomposeClusterComponents(billingDims) + if len(components) == 0 { + return nil, fmt.Errorf("%w: cluster has no components in billing dimensions", ErrDataQuality) + } + + result := make([]cloudevents.Event, 0, len(components)) + for _, comp := range components { + ce, err := buildFn(comp.FlatBillingDimensions(), ComponentEventID(baseID, comp)) + if err != nil { + return nil, err + } + result = append(result, ce) + } + return result, nil +} + // ChangedComponents compares old and new billing dimensions and returns // component records that changed: node_count differs, newly added, or removed. // Removed components are returned with NodeCount=0. diff --git a/osac-metering/metering-service/internal/events/cluster_test.go b/osac-metering/metering-service/internal/events/cluster_test.go index 9c5841cd7..761c356be 100644 --- a/osac-metering/metering-service/internal/events/cluster_test.go +++ b/osac-metering/metering-service/internal/events/cluster_test.go @@ -3,7 +3,9 @@ package events_test import ( "encoding/json" "errors" + "fmt" + cloudevents "github.com/cloudevents/sdk-go/v2" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "google.golang.org/protobuf/types/known/timestamppb" @@ -443,6 +445,73 @@ var _ = Describe("CaaS Cluster Mapper", func() { }) }) +var _ = Describe("DecomposeClusterEvents", func() { + It("produces N+1 events with per-component dims and deterministic IDs", func() { + dims := map[string]any{ + "cluster_template": "ocp-ci-small", + "components": []any{ + map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, + }, + } + + built := []string{} + result, err := events.DecomposeClusterEvents(dims, "base-id", func(d map[string]any, eventID string) (cloudevents.Event, error) { + built = append(built, eventID) + ce := cloudevents.NewEvent() + ce.SetID(eventID) + Expect(ce.SetData(cloudevents.ApplicationJSON, d)).To(Succeed()) + return ce, nil + }) + + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(HaveLen(2)) + Expect(built).To(ConsistOf("base-id/_control_plane", "base-id/gpu-workers")) + }) + + It("returns ErrDataQuality when cluster has no components", func() { + dims := map[string]any{"cluster_template": "ocp-ci-small"} + + _, err := events.DecomposeClusterEvents(dims, "base-id", func(d map[string]any, eventID string) (cloudevents.Event, error) { + Fail("buildFn should not be called") + return cloudevents.Event{}, nil + }) + + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, events.ErrDataQuality)).To(BeTrue()) + }) + + It("returns ErrDataQuality when components array is empty", func() { + dims := map[string]any{ + "cluster_template": "ocp-ci-small", + "components": []any{}, + } + + _, err := events.DecomposeClusterEvents(dims, "base-id", func(d map[string]any, eventID string) (cloudevents.Event, error) { + Fail("buildFn should not be called") + return cloudevents.Event{}, nil + }) + + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, events.ErrDataQuality)).To(BeTrue()) + }) + + It("propagates buildFn errors", func() { + dims := map[string]any{ + "components": []any{ + map[string]any{"node_set": "_control_plane", "node_count": int32(1)}, + }, + } + + _, err := events.DecomposeClusterEvents(dims, "base-id", func(d map[string]any, eventID string) (cloudevents.Event, error) { + return cloudevents.Event{}, fmt.Errorf("kafka down") + }) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("kafka down")) + }) +}) + var _ = Describe("DecomposeClusterComponents", func() { It("decomposes 1 control plane + 2 worker sets into 3 records", func() { dims := map[string]any{ diff --git a/osac-metering/metering-service/internal/heartbeat/generator.go b/osac-metering/metering-service/internal/heartbeat/generator.go index d4a1e8103..5e5ea1643 100644 --- a/osac-metering/metering-service/internal/heartbeat/generator.go +++ b/osac-metering/metering-service/internal/heartbeat/generator.go @@ -125,36 +125,18 @@ func (g *Generator) tick(ctx context.Context) error { } func (g *Generator) buildHeartbeatEvents(state *projection.ResourceState, now time.Time) ([]cloudevents.Event, error) { - if state.ResourceType != "cluster_order" { - ce, err := g.buildHeartbeatEvent(state, uuid.NewString(), state.BillingDimensions, now) - if err != nil { - return nil, err - } - return []cloudevents.Event{ce}, nil + buildFn := func(dims map[string]any, eventID string) (cloudevents.Event, error) { + return g.buildHeartbeatEvent(state, eventID, dims, now) } - components := events.DecomposeClusterComponents(state.BillingDimensions) - if len(components) == 0 { - ce, err := g.buildHeartbeatEvent(state, uuid.NewString(), state.BillingDimensions, now) - if err != nil { - return nil, err - } - return []cloudevents.Event{ce}, nil + if state.ResourceType == "cluster_order" { + return events.DecomposeClusterEvents(state.BillingDimensions, uuid.NewString(), buildFn) } - - result := make([]cloudevents.Event, 0, len(components)) - for _, comp := range components { - ce, err := g.buildComponentHeartbeat(state, comp, now) - if err != nil { - return nil, err - } - result = append(result, ce) + ce, err := buildFn(state.BillingDimensions, uuid.NewString()) + if err != nil { + return nil, err } - return result, nil -} - -func (g *Generator) buildComponentHeartbeat(state *projection.ResourceState, comp events.ComponentRecord, now time.Time) (cloudevents.Event, error) { - return g.buildHeartbeatEvent(state, events.ComponentEventID(uuid.NewString(), comp), comp.FlatBillingDimensions(), now) + return []cloudevents.Event{ce}, nil } func (g *Generator) buildHeartbeatEvent(state *projection.ResourceState, eventID string, dims map[string]any, now time.Time) (cloudevents.Event, error) { diff --git a/osac-metering/metering-service/internal/reconciliation/correction.go b/osac-metering/metering-service/internal/reconciliation/correction.go index 648599b1f..2cf98f233 100644 --- a/osac-metering/metering-service/internal/reconciliation/correction.go +++ b/osac-metering/metering-service/internal/reconciliation/correction.go @@ -74,28 +74,23 @@ func buildCorrectionEvents( now time.Time, ) ([]cloudevents.Event, error) { baseID := fmt.Sprintf("correction/%s/%s/%s/%s", resourceID, reason, projectionState, sourceState) - if resourceType == "cluster_order" { - components := events.DecomposeClusterComponents(billingDimensions) - if len(components) > 0 { - result := make([]cloudevents.Event, 0, len(components)) - for _, comp := range components { - ce, err := buildCorrectionEvent(resourceID, resourceType, tenantID, projectID, - reason, projectionState, sourceState, comp.FlatBillingDimensions(), interval, now) - if err != nil { - return nil, err - } - ce.SetID(events.ComponentEventID(baseID, comp)) - result = append(result, ce) - } - return result, nil + buildFn := func(dims map[string]any, eventID string) (cloudevents.Event, error) { + ce, err := buildCorrectionEvent(resourceID, resourceType, tenantID, projectID, + reason, projectionState, sourceState, dims, interval, now) + if err != nil { + return ce, err } + ce.SetID(eventID) + return ce, nil + } + + if resourceType == "cluster_order" { + return events.DecomposeClusterEvents(billingDimensions, baseID, buildFn) } - ce, err := buildCorrectionEvent(resourceID, resourceType, tenantID, projectID, - reason, projectionState, sourceState, billingDimensions, interval, now) + ce, err := buildFn(billingDimensions, baseID) if err != nil { return nil, err } - ce.SetID(baseID) return []cloudevents.Event{ce}, nil } diff --git a/osac-metering/metering-service/internal/reconciliation/reconciler.go b/osac-metering/metering-service/internal/reconciliation/reconciler.go index 4031da585..db0514fd2 100644 --- a/osac-metering/metering-service/internal/reconciliation/reconciler.go +++ b/osac-metering/metering-service/internal/reconciliation/reconciler.go @@ -476,21 +476,14 @@ func (r *Reconciler) loadClusters(ctx context.Context, result map[string]fulfill func buildSyntheticHeartbeats(ps projection.ResourceState, now time.Time) ([]cloudevents.Event, error) { baseID := fmt.Sprintf("synthetic-hb/%s/%d", ps.ResourceID, now.UTC().Unix()) + buildFn := func(dims map[string]any, eventID string) (cloudevents.Event, error) { + return buildSingleSyntheticHeartbeat(ps, dims, eventID, now) + } + if ps.ResourceType == "cluster_order" { - components := events.DecomposeClusterComponents(ps.BillingDimensions) - if len(components) > 0 { - result := make([]cloudevents.Event, 0, len(components)) - for _, comp := range components { - ce, err := buildSingleSyntheticHeartbeat(ps, comp.FlatBillingDimensions(), events.ComponentEventID(baseID, comp), now) - if err != nil { - return nil, err - } - result = append(result, ce) - } - return result, nil - } + return events.DecomposeClusterEvents(ps.BillingDimensions, baseID, buildFn) } - ce, err := buildSingleSyntheticHeartbeat(ps, ps.BillingDimensions, baseID, now) + ce, err := buildFn(ps.BillingDimensions, baseID) if err != nil { return nil, err } diff --git a/osac-metering/metering-service/internal/watch/consumer.go b/osac-metering/metering-service/internal/watch/consumer.go index 38f0103c7..c635cfb05 100644 --- a/osac-metering/metering-service/internal/watch/consumer.go +++ b/osac-metering/metering-service/internal/watch/consumer.go @@ -243,27 +243,26 @@ func (c *Consumer) handleTransientState( } func (c *Consumer) publishLifecycleEvents(ctx context.Context, baseCE *cloudevents.Event, mapper events.ResourceMapper, eventID string) error { - if mapper.ResourceType() != "cluster_order" || - baseCE.Type() == "osac.resource.created.v1" || - baseCE.Type() == "osac.resource.deleted.v1" { + if baseCE.Type() == "osac.resource.created.v1" || baseCE.Type() == "osac.resource.deleted.v1" { return c.publishWithRetry(ctx, baseCE) } - components := events.DecomposeClusterComponents(mapper.BillingDimensionsMap()) - if len(components) == 0 { - return c.publishWithRetry(ctx, baseCE) - } - - for _, comp := range components { - compCE, ceErr := c.buildComponentEvent(baseCE, eventID, comp) - if ceErr != nil { - return ceErr - } - if err := c.publishWithRetry(ctx, &compCE); err != nil { + if mapper.ResourceType() == "cluster_order" { + decomposed, err := events.DecomposeClusterEvents(mapper.BillingDimensionsMap(), eventID, func(dims map[string]any, compEventID string) (cloudevents.Event, error) { + return c.buildComponentEvent(baseCE, compEventID, dims) + }) + if err != nil { return err } + for i := range decomposed { + if err := c.publishWithRetry(ctx, &decomposed[i]); err != nil { + return err + } + } + return nil } - return nil + + return c.publishWithRetry(ctx, baseCE) } func (c *Consumer) handleScalingEvent(ctx context.Context, event *privatev1.Event, mapper events.ResourceMapper, existing *projection.ResourceState, transitionTime time.Time, version int32, currentState string, isBillable bool, dims map[string]any) error { @@ -294,9 +293,9 @@ func (c *Consumer) handleScalingEvent(ctx context.Context, event *privatev1.Even }, projState, resourceID) } -func (c *Consumer) buildComponentEvent(baseCE *cloudevents.Event, eventID string, comp events.ComponentRecord) (cloudevents.Event, error) { +func (c *Consumer) buildComponentEvent(baseCE *cloudevents.Event, eventID string, dims map[string]any) (cloudevents.Event, error) { ce := cloudevents.NewEvent() - ce.SetID(events.ComponentEventID(eventID, comp)) + ce.SetID(eventID) ce.SetSource(baseCE.Source()) ce.SetType(baseCE.Type()) ce.SetTime(baseCE.Time()) @@ -310,7 +309,7 @@ func (c *Consumer) buildComponentEvent(baseCE *cloudevents.Event, eventID string return ce, fmt.Errorf("reading base event data: %w", err) } - baseData["billing_dimensions"] = comp.FlatBillingDimensions() + baseData["billing_dimensions"] = dims if err := ce.SetData(cloudevents.ApplicationJSON, baseData); err != nil { return ce, fmt.Errorf("setting component event data: %w", err) } From b5e04b3dd1a0bd94d9bd76b3424a22edc12d128e Mon Sep 17 00:00:00 2001 From: omer-vishlitzky Date: Thu, 6 Aug 2026 17:00:17 +0300 Subject: [PATCH 13/18] refactor: eliminate all switch statements, wildcards, and string literals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zero switch statements in production code. Zero wildcards. Zero hardcoded string literals. All state machines, event types, resource types, timestamps, corrections, and decomposition use map-based dispatch with constants. Missing entry = error, no fallback. Changes: - Constants for all event types (EventCreated, EventSuspended, ...), resource types (ResourceTypeComputeInstance, ResourceTypeClusterOrder), VMaaS states (CIState*), CaaS states (CLState*) - ResolveCloudEventType: map-based, replaces identical switch in both mappers. CREATED/DELETED fixed, UPDATED delegates to transition table - ResolveTransitionTime: map-based, replaces identical switch in both mappers. Unknown event type = error, nil timestamp = ErrDataQuality - BuildResourceEvents: map-based decomposition dispatch, replaces 5 separate "if cluster_order" branches. Unknown resource type = error. Adding BMaaS = one line in the map - VMaaS transition table: 72 explicit entries (9 from × 8 to), no wildcards. Fixes latent bugs: ""→STOPPED was suspended.v1 (should be skip, no interval to close), RUNNING→UNSPECIFIED was updated.v1 (should be suspended.v1, billable→non-billable) - correctionDescription: map replaces switch, unknown reason = error - isBillableForType: map replaces switch, unknown type = false 318 specs pass (was 240). New test files: transitions_test.go (18 specs), correction_internal_test.go (5 specs). VMaaS exhaustive DescribeTable (72 entries) + proto enumeration completeness test. Signed-off-by: omer-vishlitzky Assisted-by: Claude Code Signed-off-by: omer-vishlitzky --- .../internal/events/cluster.go | 141 +++--- .../internal/events/cluster_test.go | 122 ++--- .../internal/events/compute_instance.go | 176 ++++--- .../internal/events/mapper_test.go | 461 ++++++++++-------- .../internal/events/transitions.go | 101 +++- .../internal/events/transitions_test.go | 257 ++++++++++ .../internal/heartbeat/generator.go | 11 +- .../internal/heartbeat/generator_test.go | 9 +- .../internal/reconciliation/correction.go | 31 +- .../correction_internal_test.go | 49 ++ .../internal/reconciliation/reconciler.go | 43 +- .../reconciliation/reconciler_test.go | 51 +- .../internal/watch/consumer.go | 26 +- .../internal/watch/consumer_test.go | 64 ++- 14 files changed, 995 insertions(+), 547 deletions(-) create mode 100644 osac-metering/metering-service/internal/events/transitions_test.go create mode 100644 osac-metering/metering-service/internal/reconciliation/correction_internal_test.go diff --git a/osac-metering/metering-service/internal/events/cluster.go b/osac-metering/metering-service/internal/events/cluster.go index 94d48342d..599411c2b 100644 --- a/osac-metering/metering-service/internal/events/cluster.go +++ b/osac-metering/metering-service/internal/events/cluster.go @@ -22,11 +22,21 @@ import ( const ClusterStatePrefix = "CLUSTER_STATE_" +// CaaS cluster state constants. +const ( + ClusterStateProgressing = "PROGRESSING" + ClusterStateReady = "READY" + ClusterStateFailed = "FAILED" + ClusterStateDeleting = "DELETING" + ClusterStateDeleteFailed = "DELETE_FAILED" + ClusterStateUnspecified = "UNSPECIFIED" +) + type clusterMapper struct { cl *privatev1.Cluster } -func (m *clusterMapper) ResourceType() string { return "cluster_order" } +func (m *clusterMapper) ResourceType() string { return ResourceTypeClusterOrder } func (m *clusterMapper) ResourceID() string { return m.cl.GetId() } func (m *clusterMapper) FulfillmentVersion() int32 { @@ -91,106 +101,77 @@ func (m *clusterMapper) BillingDimensionsMap() map[string]any { // any missed dimension drift. var clusterTransitions = TransitionTable{ // Started: first billable state (no previous) - {"", "PROGRESSING"}: {EventType: "osac.resource.started.v1"}, - {"", "READY"}: {EventType: "osac.resource.started.v1"}, + {StateEmpty, ClusterStateProgressing}: {EventType: EventStarted}, + {StateEmpty, ClusterStateReady}: {EventType: EventStarted}, // Skip: first observed in non-billable state (bootstrap, reconnect after failure) - {"", "FAILED"}: {Skip: true}, - {"", "DELETING"}: {Skip: true}, - {"", "DELETE_FAILED"}: {Skip: true}, - {"", "UNSPECIFIED"}: {Skip: true}, + {StateEmpty, ClusterStateFailed}: {Skip: true}, + {StateEmpty, ClusterStateDeleting}: {Skip: true}, + {StateEmpty, ClusterStateDeleteFailed}: {Skip: true}, + {StateEmpty, ClusterStateUnspecified}: {Skip: true}, // Resumed: non-billable to billable - {"FAILED", "PROGRESSING"}: {EventType: "osac.resource.resumed.v1"}, - {"FAILED", "READY"}: {EventType: "osac.resource.resumed.v1"}, - {"DELETING", "PROGRESSING"}: {EventType: "osac.resource.resumed.v1"}, - {"DELETING", "READY"}: {EventType: "osac.resource.resumed.v1"}, - {"DELETE_FAILED", "PROGRESSING"}: {EventType: "osac.resource.resumed.v1"}, - {"DELETE_FAILED", "READY"}: {EventType: "osac.resource.resumed.v1"}, - {"UNSPECIFIED", "PROGRESSING"}: {EventType: "osac.resource.resumed.v1"}, - {"UNSPECIFIED", "READY"}: {EventType: "osac.resource.resumed.v1"}, + {ClusterStateFailed, ClusterStateProgressing}: {EventType: EventResumed}, + {ClusterStateFailed, ClusterStateReady}: {EventType: EventResumed}, + {ClusterStateDeleting, ClusterStateProgressing}: {EventType: EventResumed}, + {ClusterStateDeleting, ClusterStateReady}: {EventType: EventResumed}, + {ClusterStateDeleteFailed, ClusterStateProgressing}: {EventType: EventResumed}, + {ClusterStateDeleteFailed, ClusterStateReady}: {EventType: EventResumed}, + {ClusterStateUnspecified, ClusterStateProgressing}: {EventType: EventResumed}, + {ClusterStateUnspecified, ClusterStateReady}: {EventType: EventResumed}, // Suspended: billable to non-billable - {"PROGRESSING", "FAILED"}: {EventType: "osac.resource.suspended.v1"}, - {"PROGRESSING", "DELETING"}: {EventType: "osac.resource.suspended.v1"}, - {"READY", "FAILED"}: {EventType: "osac.resource.suspended.v1"}, - {"READY", "DELETING"}: {EventType: "osac.resource.suspended.v1"}, + {ClusterStateProgressing, ClusterStateFailed}: {EventType: EventSuspended}, + {ClusterStateProgressing, ClusterStateDeleting}: {EventType: EventSuspended}, + {ClusterStateReady, ClusterStateFailed}: {EventType: EventSuspended}, + {ClusterStateReady, ClusterStateDeleting}: {EventType: EventSuspended}, + {ClusterStateProgressing, ClusterStateUnspecified}: {EventType: EventSuspended}, + {ClusterStateReady, ClusterStateUnspecified}: {EventType: EventSuspended}, + {ClusterStateReady, ClusterStateDeleteFailed}: {EventType: EventSuspended}, + {ClusterStateProgressing, ClusterStateDeleteFailed}: {EventType: EventSuspended}, // Skip: billable to billable (no billing boundary, includes same-state for scaling) - {"PROGRESSING", "READY"}: {Skip: true}, - {"READY", "PROGRESSING"}: {Skip: true}, - {"PROGRESSING", "PROGRESSING"}: {Skip: true}, - {"READY", "READY"}: {Skip: true}, + {ClusterStateProgressing, ClusterStateReady}: {Skip: true}, + {ClusterStateReady, ClusterStateProgressing}: {Skip: true}, + {ClusterStateProgressing, ClusterStateProgressing}: {Skip: true}, + {ClusterStateReady, ClusterStateReady}: {Skip: true}, // Skip: non-billable to non-billable (no billing effect, includes same-state) - {"FAILED", "FAILED"}: {Skip: true}, - {"DELETING", "DELETING"}: {Skip: true}, - {"DELETE_FAILED", "DELETE_FAILED"}: {Skip: true}, - {"UNSPECIFIED", "UNSPECIFIED"}: {Skip: true}, - {"FAILED", "DELETING"}: {Skip: true}, - {"FAILED", "DELETE_FAILED"}: {Skip: true}, - {"DELETING", "DELETE_FAILED"}: {Skip: true}, - {"DELETE_FAILED", "DELETING"}: {Skip: true}, - {"DELETING", "FAILED"}: {Skip: true}, - {"DELETE_FAILED", "FAILED"}: {Skip: true}, - {"UNSPECIFIED", "FAILED"}: {Skip: true}, - {"UNSPECIFIED", "DELETING"}: {Skip: true}, - {"UNSPECIFIED", "DELETE_FAILED"}: {Skip: true}, - {"FAILED", "UNSPECIFIED"}: {Skip: true}, - {"DELETING", "UNSPECIFIED"}: {Skip: true}, - {"DELETE_FAILED", "UNSPECIFIED"}: {Skip: true}, - {"PROGRESSING", "UNSPECIFIED"}: {EventType: "osac.resource.suspended.v1"}, - {"READY", "UNSPECIFIED"}: {EventType: "osac.resource.suspended.v1"}, - {"READY", "DELETE_FAILED"}: {EventType: "osac.resource.suspended.v1"}, - {"PROGRESSING", "DELETE_FAILED"}: {EventType: "osac.resource.suspended.v1"}, + {ClusterStateFailed, ClusterStateFailed}: {Skip: true}, + {ClusterStateDeleting, ClusterStateDeleting}: {Skip: true}, + {ClusterStateDeleteFailed, ClusterStateDeleteFailed}: {Skip: true}, + {ClusterStateUnspecified, ClusterStateUnspecified}: {Skip: true}, + {ClusterStateFailed, ClusterStateDeleting}: {Skip: true}, + {ClusterStateFailed, ClusterStateDeleteFailed}: {Skip: true}, + {ClusterStateDeleting, ClusterStateDeleteFailed}: {Skip: true}, + {ClusterStateDeleteFailed, ClusterStateDeleting}: {Skip: true}, + {ClusterStateDeleting, ClusterStateFailed}: {Skip: true}, + {ClusterStateDeleteFailed, ClusterStateFailed}: {Skip: true}, + {ClusterStateUnspecified, ClusterStateFailed}: {Skip: true}, + {ClusterStateUnspecified, ClusterStateDeleting}: {Skip: true}, + {ClusterStateUnspecified, ClusterStateDeleteFailed}: {Skip: true}, + {ClusterStateFailed, ClusterStateUnspecified}: {Skip: true}, + {ClusterStateDeleting, ClusterStateUnspecified}: {Skip: true}, + {ClusterStateDeleteFailed, ClusterStateUnspecified}: {Skip: true}, } func (m *clusterMapper) CloudEventType(eventType privatev1.EventType, previousState string) (string, error) { - switch eventType { - case privatev1.EventType_EVENT_TYPE_OBJECT_CREATED: - return "osac.resource.created.v1", nil - case privatev1.EventType_EVENT_TYPE_OBJECT_DELETED: - return "osac.resource.deleted.v1", nil - case privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED: - return resolveTransition(clusterTransitions, previousState, m.CurrentState()) - default: - return "", fmt.Errorf("unsupported event type: %v", eventType) - } + return ResolveCloudEventType(clusterTransitions, eventType, previousState, m.CurrentState()) } func (m *clusterMapper) TransitionTime(eventType privatev1.EventType) (time.Time, error) { - switch eventType { - case privatev1.EventType_EVENT_TYPE_OBJECT_CREATED: - if md := m.cl.GetMetadata(); md != nil { - if ct := md.GetCreationTimestamp(); ct != nil { - return ct.AsTime(), nil - } - } - return time.Time{}, fmt.Errorf("%w: cluster %s has no creation_timestamp", ErrDataQuality, m.cl.GetId()) - - case privatev1.EventType_EVENT_TYPE_OBJECT_DELETED: - if md := m.cl.GetMetadata(); md != nil { - if dt := md.GetDeletionTimestamp(); dt != nil { - return dt.AsTime(), nil - } - } - return time.Time{}, fmt.Errorf("%w: cluster %s has no deletion_timestamp", ErrDataQuality, m.cl.GetId()) - - default: - if s := m.cl.GetStatus(); s != nil { - if t := s.GetStateTransitionTime(); t != nil { - return t.AsTime(), nil - } - } - return time.Time{}, fmt.Errorf("%w: cluster %s has no state_transition_time", ErrDataQuality, m.cl.GetId()) - } + return ResolveTransitionTime(eventType, + m.cl.GetMetadata().GetCreationTimestamp(), + m.cl.GetMetadata().GetDeletionTimestamp(), + m.cl.GetStatus().GetStateTransitionTime(), + m.cl.GetId()) } // IsClusterBillableState returns whether a ClusterOrder state string represents // a billable state. Single source of truth — used by Watch Consumer, Heartbeat // Generator, and Reconciler. func IsClusterBillableState(state string) bool { - return state == "PROGRESSING" || state == "READY" + return state == ClusterStateProgressing || state == ClusterStateReady } // ClusterBillingDimensions extracts billing dimensions from a Cluster proto, @@ -321,7 +302,7 @@ func ComponentEventID(baseEventID string, comp ComponentRecord) string { // DecomposeClusterEvents fans out a single event into N+1 per-component events. // Returns error if billing dimensions have no components (data quality issue). // buildFn receives (per-component billing dimensions, deterministic event ID). -func DecomposeClusterEvents(billingDims map[string]any, baseID string, buildFn func(dims map[string]any, eventID string) (cloudevents.Event, error)) ([]cloudevents.Event, error) { +func DecomposeClusterEvents(billingDims map[string]any, baseID string, buildFn EventBuilder) ([]cloudevents.Event, error) { components := DecomposeClusterComponents(billingDims) if len(components) == 0 { return nil, fmt.Errorf("%w: cluster has no components in billing dimensions", ErrDataQuality) diff --git a/osac-metering/metering-service/internal/events/cluster_test.go b/osac-metering/metering-service/internal/events/cluster_test.go index 761c356be..d36addf66 100644 --- a/osac-metering/metering-service/internal/events/cluster_test.go +++ b/osac-metering/metering-service/internal/events/cluster_test.go @@ -68,101 +68,101 @@ var _ = Describe("CaaS Cluster Mapper", func() { }, // --- Started: first billable state (no previous) --- Entry("initial PROGRESSING (prev=empty) -> started.v1", - privatev1.ClusterState_CLUSTER_STATE_PROGRESSING, "", "osac.resource.started.v1", false), + privatev1.ClusterState_CLUSTER_STATE_PROGRESSING, events.StateEmpty, events.EventStarted, false), Entry("initial READY (prev=empty) -> started.v1", - privatev1.ClusterState_CLUSTER_STATE_READY, "", "osac.resource.started.v1", false), + privatev1.ClusterState_CLUSTER_STATE_READY, events.StateEmpty, events.EventStarted, false), // --- Skip: first observed in non-billable state --- Entry("initial FAILED (prev=empty) -> skip", - privatev1.ClusterState_CLUSTER_STATE_FAILED, "", "", true), + privatev1.ClusterState_CLUSTER_STATE_FAILED, events.StateEmpty, "", true), Entry("initial DELETING (prev=empty) -> skip", - privatev1.ClusterState_CLUSTER_STATE_DELETING, "", "", true), + privatev1.ClusterState_CLUSTER_STATE_DELETING, events.StateEmpty, "", true), Entry("initial DELETE_FAILED (prev=empty) -> skip", - privatev1.ClusterState_CLUSTER_STATE_DELETE_FAILED, "", "", true), + privatev1.ClusterState_CLUSTER_STATE_DELETE_FAILED, events.StateEmpty, "", true), Entry("initial UNSPECIFIED (prev=empty) -> skip", - privatev1.ClusterState_CLUSTER_STATE_UNSPECIFIED, "", "", true), + privatev1.ClusterState_CLUSTER_STATE_UNSPECIFIED, events.StateEmpty, "", true), // --- Resumed: non-billable to billable --- Entry("FAILED -> PROGRESSING -> resumed.v1", - privatev1.ClusterState_CLUSTER_STATE_PROGRESSING, "FAILED", "osac.resource.resumed.v1", false), + privatev1.ClusterState_CLUSTER_STATE_PROGRESSING, events.ClusterStateFailed, events.EventResumed, false), Entry("FAILED -> READY -> resumed.v1", - privatev1.ClusterState_CLUSTER_STATE_READY, "FAILED", "osac.resource.resumed.v1", false), + privatev1.ClusterState_CLUSTER_STATE_READY, events.ClusterStateFailed, events.EventResumed, false), Entry("DELETING -> PROGRESSING -> resumed.v1", - privatev1.ClusterState_CLUSTER_STATE_PROGRESSING, "DELETING", "osac.resource.resumed.v1", false), + privatev1.ClusterState_CLUSTER_STATE_PROGRESSING, events.ClusterStateDeleting, events.EventResumed, false), Entry("DELETING -> READY -> resumed.v1", - privatev1.ClusterState_CLUSTER_STATE_READY, "DELETING", "osac.resource.resumed.v1", false), + privatev1.ClusterState_CLUSTER_STATE_READY, events.ClusterStateDeleting, events.EventResumed, false), Entry("DELETE_FAILED -> PROGRESSING -> resumed.v1", - privatev1.ClusterState_CLUSTER_STATE_PROGRESSING, "DELETE_FAILED", "osac.resource.resumed.v1", false), + privatev1.ClusterState_CLUSTER_STATE_PROGRESSING, events.ClusterStateDeleteFailed, events.EventResumed, false), Entry("DELETE_FAILED -> READY -> resumed.v1", - privatev1.ClusterState_CLUSTER_STATE_READY, "DELETE_FAILED", "osac.resource.resumed.v1", false), + privatev1.ClusterState_CLUSTER_STATE_READY, events.ClusterStateDeleteFailed, events.EventResumed, false), Entry("UNSPECIFIED -> PROGRESSING -> resumed.v1", - privatev1.ClusterState_CLUSTER_STATE_PROGRESSING, "UNSPECIFIED", "osac.resource.resumed.v1", false), + privatev1.ClusterState_CLUSTER_STATE_PROGRESSING, events.ClusterStateUnspecified, events.EventResumed, false), Entry("UNSPECIFIED -> READY -> resumed.v1", - privatev1.ClusterState_CLUSTER_STATE_READY, "UNSPECIFIED", "osac.resource.resumed.v1", false), + privatev1.ClusterState_CLUSTER_STATE_READY, events.ClusterStateUnspecified, events.EventResumed, false), // --- Suspended: billable to non-billable --- Entry("PROGRESSING -> FAILED -> suspended.v1", - privatev1.ClusterState_CLUSTER_STATE_FAILED, "PROGRESSING", "osac.resource.suspended.v1", false), + privatev1.ClusterState_CLUSTER_STATE_FAILED, events.ClusterStateProgressing, events.EventSuspended, false), Entry("PROGRESSING -> DELETING -> suspended.v1", - privatev1.ClusterState_CLUSTER_STATE_DELETING, "PROGRESSING", "osac.resource.suspended.v1", false), + privatev1.ClusterState_CLUSTER_STATE_DELETING, events.ClusterStateProgressing, events.EventSuspended, false), Entry("READY -> FAILED -> suspended.v1", - privatev1.ClusterState_CLUSTER_STATE_FAILED, "READY", "osac.resource.suspended.v1", false), + privatev1.ClusterState_CLUSTER_STATE_FAILED, events.ClusterStateReady, events.EventSuspended, false), Entry("READY -> DELETING -> suspended.v1", - privatev1.ClusterState_CLUSTER_STATE_DELETING, "READY", "osac.resource.suspended.v1", false), + privatev1.ClusterState_CLUSTER_STATE_DELETING, events.ClusterStateReady, events.EventSuspended, false), Entry("PROGRESSING -> UNSPECIFIED -> suspended.v1", - privatev1.ClusterState_CLUSTER_STATE_UNSPECIFIED, "PROGRESSING", "osac.resource.suspended.v1", false), + privatev1.ClusterState_CLUSTER_STATE_UNSPECIFIED, events.ClusterStateProgressing, events.EventSuspended, false), Entry("READY -> UNSPECIFIED -> suspended.v1", - privatev1.ClusterState_CLUSTER_STATE_UNSPECIFIED, "READY", "osac.resource.suspended.v1", false), + privatev1.ClusterState_CLUSTER_STATE_UNSPECIFIED, events.ClusterStateReady, events.EventSuspended, false), Entry("READY -> DELETE_FAILED -> suspended.v1", - privatev1.ClusterState_CLUSTER_STATE_DELETE_FAILED, "READY", "osac.resource.suspended.v1", false), + privatev1.ClusterState_CLUSTER_STATE_DELETE_FAILED, events.ClusterStateReady, events.EventSuspended, false), Entry("PROGRESSING -> DELETE_FAILED -> suspended.v1", - privatev1.ClusterState_CLUSTER_STATE_DELETE_FAILED, "PROGRESSING", "osac.resource.suspended.v1", false), + privatev1.ClusterState_CLUSTER_STATE_DELETE_FAILED, events.ClusterStateProgressing, events.EventSuspended, false), // --- Skip: billable to billable (no billing boundary) --- Entry("PROGRESSING -> READY -> skip", - privatev1.ClusterState_CLUSTER_STATE_READY, "PROGRESSING", "", true), + privatev1.ClusterState_CLUSTER_STATE_READY, events.ClusterStateProgressing, "", true), Entry("READY -> PROGRESSING -> skip", - privatev1.ClusterState_CLUSTER_STATE_PROGRESSING, "READY", "", true), + privatev1.ClusterState_CLUSTER_STATE_PROGRESSING, events.ClusterStateReady, "", true), Entry("PROGRESSING -> PROGRESSING -> skip (same-state)", - privatev1.ClusterState_CLUSTER_STATE_PROGRESSING, "PROGRESSING", "", true), + privatev1.ClusterState_CLUSTER_STATE_PROGRESSING, events.ClusterStateProgressing, "", true), Entry("READY -> READY -> skip (same-state, scaling)", - privatev1.ClusterState_CLUSTER_STATE_READY, "READY", "", true), + privatev1.ClusterState_CLUSTER_STATE_READY, events.ClusterStateReady, "", true), // --- Skip: non-billable same-state --- Entry("FAILED -> FAILED -> skip (same-state)", - privatev1.ClusterState_CLUSTER_STATE_FAILED, "FAILED", "", true), + privatev1.ClusterState_CLUSTER_STATE_FAILED, events.ClusterStateFailed, "", true), Entry("DELETING -> DELETING -> skip (same-state)", - privatev1.ClusterState_CLUSTER_STATE_DELETING, "DELETING", "", true), + privatev1.ClusterState_CLUSTER_STATE_DELETING, events.ClusterStateDeleting, "", true), Entry("DELETE_FAILED -> DELETE_FAILED -> skip (same-state)", - privatev1.ClusterState_CLUSTER_STATE_DELETE_FAILED, "DELETE_FAILED", "", true), + privatev1.ClusterState_CLUSTER_STATE_DELETE_FAILED, events.ClusterStateDeleteFailed, "", true), Entry("UNSPECIFIED -> UNSPECIFIED -> skip (same-state)", - privatev1.ClusterState_CLUSTER_STATE_UNSPECIFIED, "UNSPECIFIED", "", true), + privatev1.ClusterState_CLUSTER_STATE_UNSPECIFIED, events.ClusterStateUnspecified, "", true), // --- Skip: non-billable to non-billable (cross-state) --- Entry("FAILED -> DELETING -> skip", - privatev1.ClusterState_CLUSTER_STATE_DELETING, "FAILED", "", true), + privatev1.ClusterState_CLUSTER_STATE_DELETING, events.ClusterStateFailed, "", true), Entry("FAILED -> DELETE_FAILED -> skip", - privatev1.ClusterState_CLUSTER_STATE_DELETE_FAILED, "FAILED", "", true), + privatev1.ClusterState_CLUSTER_STATE_DELETE_FAILED, events.ClusterStateFailed, "", true), Entry("DELETING -> DELETE_FAILED -> skip", - privatev1.ClusterState_CLUSTER_STATE_DELETE_FAILED, "DELETING", "", true), + privatev1.ClusterState_CLUSTER_STATE_DELETE_FAILED, events.ClusterStateDeleting, "", true), Entry("DELETE_FAILED -> DELETING -> skip", - privatev1.ClusterState_CLUSTER_STATE_DELETING, "DELETE_FAILED", "", true), + privatev1.ClusterState_CLUSTER_STATE_DELETING, events.ClusterStateDeleteFailed, "", true), Entry("DELETING -> FAILED -> skip", - privatev1.ClusterState_CLUSTER_STATE_FAILED, "DELETING", "", true), + privatev1.ClusterState_CLUSTER_STATE_FAILED, events.ClusterStateDeleting, "", true), Entry("DELETE_FAILED -> FAILED -> skip", - privatev1.ClusterState_CLUSTER_STATE_FAILED, "DELETE_FAILED", "", true), + privatev1.ClusterState_CLUSTER_STATE_FAILED, events.ClusterStateDeleteFailed, "", true), Entry("UNSPECIFIED -> FAILED -> skip", - privatev1.ClusterState_CLUSTER_STATE_FAILED, "UNSPECIFIED", "", true), + privatev1.ClusterState_CLUSTER_STATE_FAILED, events.ClusterStateUnspecified, "", true), Entry("UNSPECIFIED -> DELETING -> skip", - privatev1.ClusterState_CLUSTER_STATE_DELETING, "UNSPECIFIED", "", true), + privatev1.ClusterState_CLUSTER_STATE_DELETING, events.ClusterStateUnspecified, "", true), Entry("UNSPECIFIED -> DELETE_FAILED -> skip", - privatev1.ClusterState_CLUSTER_STATE_DELETE_FAILED, "UNSPECIFIED", "", true), + privatev1.ClusterState_CLUSTER_STATE_DELETE_FAILED, events.ClusterStateUnspecified, "", true), Entry("FAILED -> UNSPECIFIED -> skip", - privatev1.ClusterState_CLUSTER_STATE_UNSPECIFIED, "FAILED", "", true), + privatev1.ClusterState_CLUSTER_STATE_UNSPECIFIED, events.ClusterStateFailed, "", true), Entry("DELETING -> UNSPECIFIED -> skip", - privatev1.ClusterState_CLUSTER_STATE_UNSPECIFIED, "DELETING", "", true), + privatev1.ClusterState_CLUSTER_STATE_UNSPECIFIED, events.ClusterStateDeleting, "", true), Entry("DELETE_FAILED -> UNSPECIFIED -> skip", - privatev1.ClusterState_CLUSTER_STATE_UNSPECIFIED, "DELETE_FAILED", "", true), + privatev1.ClusterState_CLUSTER_STATE_UNSPECIFIED, events.ClusterStateDeleteFailed, "", true), ) It("returns error for unknown state (missing table entry)", func() { @@ -174,7 +174,7 @@ var _ = Describe("CaaS Cluster Mapper", func() { Payload: &privatev1.Event_Cluster{Cluster: cl}, } - _, err := mapEvent(event, &events.StateContext{PreviousState: "READY"}) + _, err := mapEvent(event, &events.StateContext{PreviousState: events.ClusterStateReady}) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("unexpected state transition")) Expect(errors.Is(err, events.ErrSkipTransition)).To(BeFalse()) @@ -189,7 +189,7 @@ var _ = Describe("CaaS Cluster Mapper", func() { ce, err := mapEvent(event, &events.StateContext{}) Expect(err).NotTo(HaveOccurred()) - Expect(ce.Type()).To(Equal("osac.resource.created.v1")) + Expect(ce.Type()).To(Equal(events.EventCreated)) }) It("maps OBJECT_DELETED to deleted.v1", func() { @@ -203,7 +203,7 @@ var _ = Describe("CaaS Cluster Mapper", func() { ce, err := mapEvent(event, &events.StateContext{}) Expect(err).NotTo(HaveOccurred()) - Expect(ce.Type()).To(Equal("osac.resource.deleted.v1")) + Expect(ce.Type()).To(Equal(events.EventDeleted)) }) }) @@ -220,7 +220,7 @@ var _ = Describe("CaaS Cluster Mapper", func() { var data map[string]any Expect(json.Unmarshal(ce.Data(), &data)).To(Succeed()) - Expect(data["resource_type"]).To(Equal("cluster_order")) + Expect(data["resource_type"]).To(Equal(events.ResourceTypeClusterOrder)) }) It("extracts resource_id from cluster ID", func() { @@ -266,33 +266,33 @@ var _ = Describe("CaaS Cluster Mapper", func() { var data map[string]any Expect(json.Unmarshal(ce.Data(), &data)).To(Succeed()) - Expect(data["current_state"]).To(Equal("READY")) + Expect(data["current_state"]).To(Equal(events.ClusterStateReady)) }) }) Context("billability", func() { It("PROGRESSING is billable", func() { - Expect(events.IsClusterBillableState("PROGRESSING")).To(BeTrue()) + Expect(events.IsClusterBillableState(events.ClusterStateProgressing)).To(BeTrue()) }) It("READY is billable", func() { - Expect(events.IsClusterBillableState("READY")).To(BeTrue()) + Expect(events.IsClusterBillableState(events.ClusterStateReady)).To(BeTrue()) }) It("FAILED is not billable", func() { - Expect(events.IsClusterBillableState("FAILED")).To(BeFalse()) + Expect(events.IsClusterBillableState(events.ClusterStateFailed)).To(BeFalse()) }) It("DELETING is not billable", func() { - Expect(events.IsClusterBillableState("DELETING")).To(BeFalse()) + Expect(events.IsClusterBillableState(events.ClusterStateDeleting)).To(BeFalse()) }) It("DELETE_FAILED is not billable", func() { - Expect(events.IsClusterBillableState("DELETE_FAILED")).To(BeFalse()) + Expect(events.IsClusterBillableState(events.ClusterStateDeleteFailed)).To(BeFalse()) }) It("UNSPECIFIED is not billable", func() { - Expect(events.IsClusterBillableState("UNSPECIFIED")).To(BeFalse()) + Expect(events.IsClusterBillableState(events.ClusterStateUnspecified)).To(BeFalse()) }) }) @@ -913,17 +913,17 @@ var _ = Describe("DimensionsEqual with nested CaaS components", func() { var _ = Describe("CaaS transition table completeness", func() { stateProtoMap := map[string]privatev1.ClusterState{ - "PROGRESSING": privatev1.ClusterState_CLUSTER_STATE_PROGRESSING, - "READY": privatev1.ClusterState_CLUSTER_STATE_READY, - "FAILED": privatev1.ClusterState_CLUSTER_STATE_FAILED, - "DELETING": privatev1.ClusterState_CLUSTER_STATE_DELETING, - "DELETE_FAILED": privatev1.ClusterState_CLUSTER_STATE_DELETE_FAILED, - "UNSPECIFIED": privatev1.ClusterState_CLUSTER_STATE_UNSPECIFIED, + events.ClusterStateProgressing: privatev1.ClusterState_CLUSTER_STATE_PROGRESSING, + events.ClusterStateReady: privatev1.ClusterState_CLUSTER_STATE_READY, + events.ClusterStateFailed: privatev1.ClusterState_CLUSTER_STATE_FAILED, + events.ClusterStateDeleting: privatev1.ClusterState_CLUSTER_STATE_DELETING, + events.ClusterStateDeleteFailed: privatev1.ClusterState_CLUSTER_STATE_DELETE_FAILED, + events.ClusterStateUnspecified: privatev1.ClusterState_CLUSTER_STATE_UNSPECIFIED, } It("covers every (from, to) state pair from all proto states plus empty initial", func() { - fromStates := []string{"", "PROGRESSING", "READY", "FAILED", "DELETING", "DELETE_FAILED", "UNSPECIFIED"} - toStates := []string{"PROGRESSING", "READY", "FAILED", "DELETING", "DELETE_FAILED", "UNSPECIFIED"} + fromStates := []string{events.StateEmpty, events.ClusterStateProgressing, events.ClusterStateReady, events.ClusterStateFailed, events.ClusterStateDeleting, events.ClusterStateDeleteFailed, events.ClusterStateUnspecified} + toStates := []string{events.ClusterStateProgressing, events.ClusterStateReady, events.ClusterStateFailed, events.ClusterStateDeleting, events.ClusterStateDeleteFailed, events.ClusterStateUnspecified} for _, from := range fromStates { for _, to := range toStates { diff --git a/osac-metering/metering-service/internal/events/compute_instance.go b/osac-metering/metering-service/internal/events/compute_instance.go index 2bf3c3ee6..f45656de6 100644 --- a/osac-metering/metering-service/internal/events/compute_instance.go +++ b/osac-metering/metering-service/internal/events/compute_instance.go @@ -1,7 +1,6 @@ package events import ( - "fmt" "strings" "time" @@ -10,37 +9,121 @@ import ( const ComputeInstanceStatePrefix = "COMPUTE_INSTANCE_STATE_" -// Compute instance state machine. The table IS the spec: -// - Exact (from, to) match takes priority over wildcard -// - Missing entry = error (fail fast) -// - Transient: projection-only, no CloudEvent (billing context preserved) -var computeInstanceTransitions = TransitionTable{ - // Resumptions: specific previous states take priority over wildcard - {"STOPPED", "RUNNING"}: {EventType: "osac.resource.resumed.v1"}, - {"PAUSED", "RUNNING"}: {EventType: "osac.resource.resumed.v1"}, - - // Started: any other previous state transitioning to RUNNING - {"*", "RUNNING"}: {EventType: "osac.resource.started.v1"}, - - // Suspended: billing interval closes - {"*", "STOPPED"}: {EventType: "osac.resource.suspended.v1"}, - {"*", "PAUSED"}: {EventType: "osac.resource.suspended.v1"}, - {"*", "FAILED"}: {EventType: "osac.resource.suspended.v1"}, - {"*", "DELETING"}: {EventType: "osac.resource.suspended.v1"}, - - // Transient: intermediate states, preserve billing context - {"*", "STOPPING"}: {Transient: true}, - {"*", "STARTING"}: {Transient: true}, +// VMaaS compute instance state constants. +const ( + ComputeInstanceStateRunning = "RUNNING" + ComputeInstanceStateStopped = "STOPPED" + ComputeInstanceStatePaused = "PAUSED" + ComputeInstanceStateFailed = "FAILED" + ComputeInstanceStateStopping = "STOPPING" + ComputeInstanceStateStarting = "STARTING" + ComputeInstanceStateDeleting = "DELETING" + ComputeInstanceStateUnspecified = "UNSPECIFIED" +) - // Updated: no billing effect - {"*", "UNSPECIFIED"}: {EventType: "osac.resource.updated.v1"}, +// Compute instance state machine. Every (from, to) pair is enumerated +// explicitly — no wildcards. Missing entry = error (fail fast). +// +// Billable: RUNNING +// Non-billable: STOPPED, PAUSED, FAILED, DELETING, UNSPECIFIED +// Transient: STOPPING, STARTING +var computeInstanceTransitions = TransitionTable{ + // --- From "" (initial observation) --- + {StateEmpty, ComputeInstanceStateRunning}: {EventType: EventStarted}, + {StateEmpty, ComputeInstanceStateStopped}: {Skip: true}, + {StateEmpty, ComputeInstanceStatePaused}: {Skip: true}, + {StateEmpty, ComputeInstanceStateFailed}: {Skip: true}, + {StateEmpty, ComputeInstanceStateStopping}: {Transient: true}, + {StateEmpty, ComputeInstanceStateStarting}: {Transient: true}, + {StateEmpty, ComputeInstanceStateDeleting}: {Skip: true}, + {StateEmpty, ComputeInstanceStateUnspecified}: {Skip: true}, + + // --- From RUNNING --- + {ComputeInstanceStateRunning, ComputeInstanceStateRunning}: {Skip: true}, + {ComputeInstanceStateRunning, ComputeInstanceStateStopped}: {EventType: EventSuspended}, + {ComputeInstanceStateRunning, ComputeInstanceStatePaused}: {EventType: EventSuspended}, + {ComputeInstanceStateRunning, ComputeInstanceStateFailed}: {EventType: EventSuspended}, + {ComputeInstanceStateRunning, ComputeInstanceStateStopping}: {Transient: true}, + {ComputeInstanceStateRunning, ComputeInstanceStateStarting}: {Transient: true}, + {ComputeInstanceStateRunning, ComputeInstanceStateDeleting}: {EventType: EventSuspended}, + {ComputeInstanceStateRunning, ComputeInstanceStateUnspecified}: {EventType: EventSuspended}, + + // --- From STOPPED --- + {ComputeInstanceStateStopped, ComputeInstanceStateRunning}: {EventType: EventResumed}, + {ComputeInstanceStateStopped, ComputeInstanceStateStopped}: {Skip: true}, + {ComputeInstanceStateStopped, ComputeInstanceStatePaused}: {Skip: true}, + {ComputeInstanceStateStopped, ComputeInstanceStateFailed}: {Skip: true}, + {ComputeInstanceStateStopped, ComputeInstanceStateStopping}: {Transient: true}, + {ComputeInstanceStateStopped, ComputeInstanceStateStarting}: {Transient: true}, + {ComputeInstanceStateStopped, ComputeInstanceStateDeleting}: {Skip: true}, + {ComputeInstanceStateStopped, ComputeInstanceStateUnspecified}: {Skip: true}, + + // --- From PAUSED --- + {ComputeInstanceStatePaused, ComputeInstanceStateRunning}: {EventType: EventResumed}, + {ComputeInstanceStatePaused, ComputeInstanceStateStopped}: {Skip: true}, + {ComputeInstanceStatePaused, ComputeInstanceStatePaused}: {Skip: true}, + {ComputeInstanceStatePaused, ComputeInstanceStateFailed}: {Skip: true}, + {ComputeInstanceStatePaused, ComputeInstanceStateStopping}: {Transient: true}, + {ComputeInstanceStatePaused, ComputeInstanceStateStarting}: {Transient: true}, + {ComputeInstanceStatePaused, ComputeInstanceStateDeleting}: {Skip: true}, + {ComputeInstanceStatePaused, ComputeInstanceStateUnspecified}: {Skip: true}, + + // --- From FAILED --- + {ComputeInstanceStateFailed, ComputeInstanceStateRunning}: {EventType: EventStarted}, + {ComputeInstanceStateFailed, ComputeInstanceStateStopped}: {Skip: true}, + {ComputeInstanceStateFailed, ComputeInstanceStatePaused}: {Skip: true}, + {ComputeInstanceStateFailed, ComputeInstanceStateFailed}: {Skip: true}, + {ComputeInstanceStateFailed, ComputeInstanceStateStopping}: {Transient: true}, + {ComputeInstanceStateFailed, ComputeInstanceStateStarting}: {Transient: true}, + {ComputeInstanceStateFailed, ComputeInstanceStateDeleting}: {Skip: true}, + {ComputeInstanceStateFailed, ComputeInstanceStateUnspecified}: {Skip: true}, + + // --- From STOPPING --- + {ComputeInstanceStateStopping, ComputeInstanceStateRunning}: {EventType: EventStarted}, + {ComputeInstanceStateStopping, ComputeInstanceStateStopped}: {Skip: true}, + {ComputeInstanceStateStopping, ComputeInstanceStatePaused}: {Skip: true}, + {ComputeInstanceStateStopping, ComputeInstanceStateFailed}: {Skip: true}, + {ComputeInstanceStateStopping, ComputeInstanceStateStopping}: {Skip: true}, + {ComputeInstanceStateStopping, ComputeInstanceStateStarting}: {Transient: true}, + {ComputeInstanceStateStopping, ComputeInstanceStateDeleting}: {Skip: true}, + {ComputeInstanceStateStopping, ComputeInstanceStateUnspecified}: {Skip: true}, + + // --- From STARTING --- + {ComputeInstanceStateStarting, ComputeInstanceStateRunning}: {EventType: EventStarted}, + {ComputeInstanceStateStarting, ComputeInstanceStateStopped}: {Skip: true}, + {ComputeInstanceStateStarting, ComputeInstanceStatePaused}: {Skip: true}, + {ComputeInstanceStateStarting, ComputeInstanceStateFailed}: {Skip: true}, + {ComputeInstanceStateStarting, ComputeInstanceStateStopping}: {Transient: true}, + {ComputeInstanceStateStarting, ComputeInstanceStateStarting}: {Skip: true}, + {ComputeInstanceStateStarting, ComputeInstanceStateDeleting}: {Skip: true}, + {ComputeInstanceStateStarting, ComputeInstanceStateUnspecified}: {Skip: true}, + + // --- From DELETING --- + {ComputeInstanceStateDeleting, ComputeInstanceStateRunning}: {EventType: EventStarted}, + {ComputeInstanceStateDeleting, ComputeInstanceStateStopped}: {Skip: true}, + {ComputeInstanceStateDeleting, ComputeInstanceStatePaused}: {Skip: true}, + {ComputeInstanceStateDeleting, ComputeInstanceStateFailed}: {Skip: true}, + {ComputeInstanceStateDeleting, ComputeInstanceStateStopping}: {Transient: true}, + {ComputeInstanceStateDeleting, ComputeInstanceStateStarting}: {Transient: true}, + {ComputeInstanceStateDeleting, ComputeInstanceStateDeleting}: {Skip: true}, + {ComputeInstanceStateDeleting, ComputeInstanceStateUnspecified}: {Skip: true}, + + // --- From UNSPECIFIED --- + {ComputeInstanceStateUnspecified, ComputeInstanceStateRunning}: {EventType: EventStarted}, + {ComputeInstanceStateUnspecified, ComputeInstanceStateStopped}: {Skip: true}, + {ComputeInstanceStateUnspecified, ComputeInstanceStatePaused}: {Skip: true}, + {ComputeInstanceStateUnspecified, ComputeInstanceStateFailed}: {Skip: true}, + {ComputeInstanceStateUnspecified, ComputeInstanceStateStopping}: {Transient: true}, + {ComputeInstanceStateUnspecified, ComputeInstanceStateStarting}: {Transient: true}, + {ComputeInstanceStateUnspecified, ComputeInstanceStateDeleting}: {Skip: true}, + {ComputeInstanceStateUnspecified, ComputeInstanceStateUnspecified}: {Skip: true}, } type computeInstanceMapper struct { ci *privatev1.ComputeInstance } -func (m *computeInstanceMapper) ResourceType() string { return "compute_instance" } +func (m *computeInstanceMapper) ResourceType() string { return ResourceTypeComputeInstance } func (m *computeInstanceMapper) ResourceID() string { return m.ci.GetId() } func (m *computeInstanceMapper) FulfillmentVersion() int32 { @@ -83,7 +166,7 @@ func ComputeInstanceBillingDimensions(ci *privatev1.ComputeInstance) map[string] // a billable state. Single source of truth for billability — used by both // the Watch Consumer (via IsBillable) and the Reconciler. func IsBillableState(state string) bool { - return state == "RUNNING" + return state == ComputeInstanceStateRunning } func (m *computeInstanceMapper) TenantID() string { @@ -127,42 +210,13 @@ func (m *computeInstanceMapper) CurrentState() string { } func (m *computeInstanceMapper) CloudEventType(eventType privatev1.EventType, previousState string) (string, error) { - switch eventType { - case privatev1.EventType_EVENT_TYPE_OBJECT_CREATED: - return "osac.resource.created.v1", nil - case privatev1.EventType_EVENT_TYPE_OBJECT_DELETED: - return "osac.resource.deleted.v1", nil - case privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED: - return resolveTransition(computeInstanceTransitions, previousState, m.CurrentState()) - default: - return "", fmt.Errorf("unsupported event type: %v", eventType) - } + return ResolveCloudEventType(computeInstanceTransitions, eventType, previousState, m.CurrentState()) } func (m *computeInstanceMapper) TransitionTime(eventType privatev1.EventType) (time.Time, error) { - switch eventType { - case privatev1.EventType_EVENT_TYPE_OBJECT_CREATED: - if md := m.ci.GetMetadata(); md != nil { - if ct := md.GetCreationTimestamp(); ct != nil { - return ct.AsTime(), nil - } - } - return time.Time{}, fmt.Errorf("%w: event %s has no creation_timestamp", ErrDataQuality, m.ci.GetId()) - - case privatev1.EventType_EVENT_TYPE_OBJECT_DELETED: - if md := m.ci.GetMetadata(); md != nil { - if dt := md.GetDeletionTimestamp(); dt != nil { - return dt.AsTime(), nil - } - } - return time.Time{}, fmt.Errorf("%w: event %s has no deletion_timestamp", ErrDataQuality, m.ci.GetId()) - - default: - if s := m.ci.GetStatus(); s != nil { - if t := s.GetStateTransitionTime(); t != nil { - return t.AsTime(), nil - } - } - return time.Time{}, fmt.Errorf("%w: event %s has no state_transition_time", ErrDataQuality, m.ci.GetId()) - } + return ResolveTransitionTime(eventType, + m.ci.GetMetadata().GetCreationTimestamp(), + m.ci.GetMetadata().GetDeletionTimestamp(), + m.ci.GetStatus().GetStateTransitionTime(), + m.ci.GetId()) } diff --git a/osac-metering/metering-service/internal/events/mapper_test.go b/osac-metering/metering-service/internal/events/mapper_test.go index 0c71f3809..5f4724c6e 100644 --- a/osac-metering/metering-service/internal/events/mapper_test.go +++ b/osac-metering/metering-service/internal/events/mapper_test.go @@ -55,208 +55,193 @@ var _ = Describe("MapWatchEvent", func() { } }) - Context("event type mapping", func() { - It("maps OBJECT_CREATED to osac.resource.created.v1", func() { - event := &privatev1.Event{ - Id: "evt-1", - Type: privatev1.EventType_EVENT_TYPE_OBJECT_CREATED, - Payload: &privatev1.Event_ComputeInstance{ComputeInstance: ci}, - } - - ce, err := mapEvent(event, &events.StateContext{}) - Expect(err).NotTo(HaveOccurred()) - Expect(ce.Type()).To(Equal("osac.resource.created.v1")) - }) - - It("maps OBJECT_UPDATED with RUNNING state to osac.resource.started.v1", func() { - ci.Status.State = privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_RUNNING - - event := &privatev1.Event{ - Id: "evt-2", - Type: privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, - Payload: &privatev1.Event_ComputeInstance{ComputeInstance: ci}, - } - - ce, err := mapEvent(event, &events.StateContext{}) - Expect(err).NotTo(HaveOccurred()) - Expect(ce.Type()).To(Equal("osac.resource.started.v1")) - }) - - It("maps OBJECT_UPDATED with STOPPED state to osac.resource.suspended.v1", func() { - ci.Status.State = privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STOPPED - - event := &privatev1.Event{ - Id: "evt-2", - Type: privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, - Payload: &privatev1.Event_ComputeInstance{ComputeInstance: ci}, - } - - ce, err := mapEvent(event, &events.StateContext{}) - Expect(err).NotTo(HaveOccurred()) - Expect(ce.Type()).To(Equal("osac.resource.suspended.v1")) - }) - - It("maps OBJECT_UPDATED with PAUSED state to osac.resource.suspended.v1", func() { - ci.Status.State = privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_PAUSED - - event := &privatev1.Event{ - Id: "evt-2", - Type: privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, - Payload: &privatev1.Event_ComputeInstance{ComputeInstance: ci}, - } - - ce, err := mapEvent(event, &events.StateContext{}) - Expect(err).NotTo(HaveOccurred()) - Expect(ce.Type()).To(Equal("osac.resource.suspended.v1")) - }) - - It("maps OBJECT_UPDATED with FAILED state to osac.resource.suspended.v1", func() { - ci.Status.State = privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_FAILED - - event := &privatev1.Event{ - Id: "evt-2", - Type: privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, - Payload: &privatev1.Event_ComputeInstance{ComputeInstance: ci}, - } - - ce, err := mapEvent(event, &events.StateContext{}) - Expect(err).NotTo(HaveOccurred()) - Expect(ce.Type()).To(Equal("osac.resource.suspended.v1")) - }) - - It("returns ErrTransientState for STARTING state", func() { - ci.Status.State = privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STARTING - - event := &privatev1.Event{ - Id: "evt-2", - Type: privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, - Payload: &privatev1.Event_ComputeInstance{ComputeInstance: ci}, - } - - _, err := mapEvent(event, &events.StateContext{}) - Expect(err).To(HaveOccurred()) - Expect(errors.Is(err, events.ErrTransientState)).To(BeTrue()) - }) - - It("returns ErrTransientState for STOPPING state", func() { - ci.Status.State = privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STOPPING - - event := &privatev1.Event{ - Id: "evt-2", - Type: privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, - Payload: &privatev1.Event_ComputeInstance{ComputeInstance: ci}, - } - - _, err := mapEvent(event, &events.StateContext{}) - Expect(err).To(HaveOccurred()) - Expect(errors.Is(err, events.ErrTransientState)).To(BeTrue()) - }) - - It("maps DELETING to osac.resource.suspended.v1", func() { - ci.Status.State = privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_DELETING - - event := &privatev1.Event{ - Id: "evt-deleting", - Type: privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, - Payload: &privatev1.Event_ComputeInstance{ComputeInstance: ci}, - } - - stateCtx := &events.StateContext{PreviousState: "RUNNING"} - ce, err := mapEvent(event, stateCtx) - Expect(err).NotTo(HaveOccurred()) - Expect(ce.Type()).To(Equal("osac.resource.suspended.v1")) - }) - - It("maps STOPPED→RUNNING to osac.resource.resumed.v1 with state context", func() { - ci.Status.State = privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_RUNNING - - event := &privatev1.Event{ - Id: "evt-resumed-1", - Type: privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, - Payload: &privatev1.Event_ComputeInstance{ComputeInstance: ci}, - } - - stateCtx := &events.StateContext{PreviousState: "STOPPED"} - ce, err := mapEvent(event, stateCtx) - Expect(err).NotTo(HaveOccurred()) - Expect(ce.Type()).To(Equal("osac.resource.resumed.v1")) - }) - - It("maps PAUSED→RUNNING to osac.resource.resumed.v1 with state context", func() { - ci.Status.State = privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_RUNNING - - event := &privatev1.Event{ - Id: "evt-resumed-2", - Type: privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, - Payload: &privatev1.Event_ComputeInstance{ComputeInstance: ci}, - } - - stateCtx := &events.StateContext{PreviousState: "PAUSED"} - ce, err := mapEvent(event, stateCtx) - Expect(err).NotTo(HaveOccurred()) - Expect(ce.Type()).To(Equal("osac.resource.resumed.v1")) - }) - - It("maps STARTING→RUNNING to osac.resource.started.v1 with state context", func() { - ci.Status.State = privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_RUNNING - - event := &privatev1.Event{ - Id: "evt-started", - Type: privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, - Payload: &privatev1.Event_ComputeInstance{ComputeInstance: ci}, - } - - stateCtx := &events.StateContext{PreviousState: "STARTING"} - ce, err := mapEvent(event, stateCtx) - Expect(err).NotTo(HaveOccurred()) - Expect(ce.Type()).To(Equal("osac.resource.started.v1")) - }) - - It("maps FAILED→RUNNING to osac.resource.started.v1", func() { - ci.Status.State = privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_RUNNING - - event := &privatev1.Event{ - Id: "evt-failed-to-running", - Type: privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, - Payload: &privatev1.Event_ComputeInstance{ComputeInstance: ci}, - } - - stateCtx := &events.StateContext{PreviousState: "FAILED"} - ce, err := mapEvent(event, stateCtx) - Expect(err).NotTo(HaveOccurred()) - Expect(ce.Type()).To(Equal("osac.resource.started.v1")) - }) - - It("maps RUNNING→RUNNING (prev=RUNNING) to osac.resource.started.v1", func() { - ci.Status.State = privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_RUNNING - - event := &privatev1.Event{ - Id: "evt-running-to-running", - Type: privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, - Payload: &privatev1.Event_ComputeInstance{ComputeInstance: ci}, - } - - stateCtx := &events.StateContext{PreviousState: "RUNNING"} - ce, err := mapEvent(event, stateCtx) - Expect(err).NotTo(HaveOccurred()) - Expect(ce.Type()).To(Equal("osac.resource.started.v1")) - }) - - It("maps UNSPECIFIED to osac.resource.updated.v1", func() { - ci.Status.State = privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_UNSPECIFIED - - event := &privatev1.Event{ - Id: "evt-unspecified", - Type: privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, - Payload: &privatev1.Event_ComputeInstance{ComputeInstance: ci}, - } - - ce, err := mapEvent(event, &events.StateContext{}) - Expect(err).NotTo(HaveOccurred()) - Expect(ce.Type()).To(Equal("osac.resource.updated.v1")) - }) + Context("VMaaS state machine -- full transition matrix", func() { + DescribeTable("resolves correct CloudEvent type for state transitions", + func(currentState privatev1.ComputeInstanceState, previousState string, expectedType string, expectSkip, expectTransient bool) { + ci.Status.State = currentState + event := &privatev1.Event{ + Id: "evt-1", + Type: privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, + Payload: &privatev1.Event_ComputeInstance{ComputeInstance: ci}, + } + stateCtx := &events.StateContext{PreviousState: previousState} + ce, err := mapEvent(event, stateCtx) + if expectSkip { + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, events.ErrSkipTransition)).To(BeTrue()) + } else if expectTransient { + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, events.ErrTransientState)).To(BeTrue()) + } else { + Expect(err).NotTo(HaveOccurred()) + Expect(ce.Type()).To(Equal(expectedType)) + } + }, - It("returns error for unknown state (default branch)", func() { + // --- From "" (initial observation) --- + Entry("initial -> RUNNING -> started.v1", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_RUNNING, "", events.EventStarted, false, false), + Entry("initial -> STOPPED -> skip", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STOPPED, "", "", true, false), + Entry("initial -> PAUSED -> skip", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_PAUSED, "", "", true, false), + Entry("initial -> FAILED -> skip", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_FAILED, "", "", true, false), + Entry("initial -> STOPPING -> transient", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STOPPING, "", "", false, true), + Entry("initial -> STARTING -> transient", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STARTING, "", "", false, true), + Entry("initial -> DELETING -> skip", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_DELETING, "", "", true, false), + Entry("initial -> UNSPECIFIED -> skip", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_UNSPECIFIED, "", "", true, false), + + // --- From RUNNING --- + Entry("RUNNING -> RUNNING -> skip (same-state)", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_RUNNING, "RUNNING", "", true, false), + Entry("RUNNING -> STOPPED -> suspended.v1", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STOPPED, "RUNNING", events.EventSuspended, false, false), + Entry("RUNNING -> PAUSED -> suspended.v1", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_PAUSED, "RUNNING", events.EventSuspended, false, false), + Entry("RUNNING -> FAILED -> suspended.v1", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_FAILED, "RUNNING", events.EventSuspended, false, false), + Entry("RUNNING -> STOPPING -> transient", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STOPPING, "RUNNING", "", false, true), + Entry("RUNNING -> STARTING -> transient", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STARTING, "RUNNING", "", false, true), + Entry("RUNNING -> DELETING -> suspended.v1", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_DELETING, "RUNNING", events.EventSuspended, false, false), + Entry("RUNNING -> UNSPECIFIED -> suspended.v1", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_UNSPECIFIED, "RUNNING", events.EventSuspended, false, false), + + // --- From STOPPED --- + Entry("STOPPED -> RUNNING -> resumed.v1", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_RUNNING, "STOPPED", events.EventResumed, false, false), + Entry("STOPPED -> STOPPED -> skip (same-state)", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STOPPED, "STOPPED", "", true, false), + Entry("STOPPED -> PAUSED -> skip", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_PAUSED, "STOPPED", "", true, false), + Entry("STOPPED -> FAILED -> skip", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_FAILED, "STOPPED", "", true, false), + Entry("STOPPED -> STOPPING -> transient", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STOPPING, "STOPPED", "", false, true), + Entry("STOPPED -> STARTING -> transient", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STARTING, "STOPPED", "", false, true), + Entry("STOPPED -> DELETING -> skip", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_DELETING, "STOPPED", "", true, false), + Entry("STOPPED -> UNSPECIFIED -> skip", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_UNSPECIFIED, "STOPPED", "", true, false), + + // --- From PAUSED --- + Entry("PAUSED -> RUNNING -> resumed.v1", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_RUNNING, "PAUSED", events.EventResumed, false, false), + Entry("PAUSED -> STOPPED -> skip", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STOPPED, "PAUSED", "", true, false), + Entry("PAUSED -> PAUSED -> skip (same-state)", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_PAUSED, "PAUSED", "", true, false), + Entry("PAUSED -> FAILED -> skip", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_FAILED, "PAUSED", "", true, false), + Entry("PAUSED -> STOPPING -> transient", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STOPPING, "PAUSED", "", false, true), + Entry("PAUSED -> STARTING -> transient", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STARTING, "PAUSED", "", false, true), + Entry("PAUSED -> DELETING -> skip", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_DELETING, "PAUSED", "", true, false), + Entry("PAUSED -> UNSPECIFIED -> skip", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_UNSPECIFIED, "PAUSED", "", true, false), + + // --- From FAILED --- + Entry("FAILED -> RUNNING -> started.v1", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_RUNNING, "FAILED", events.EventStarted, false, false), + Entry("FAILED -> STOPPED -> skip", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STOPPED, "FAILED", "", true, false), + Entry("FAILED -> PAUSED -> skip", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_PAUSED, "FAILED", "", true, false), + Entry("FAILED -> FAILED -> skip (same-state)", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_FAILED, "FAILED", "", true, false), + Entry("FAILED -> STOPPING -> transient", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STOPPING, "FAILED", "", false, true), + Entry("FAILED -> STARTING -> transient", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STARTING, "FAILED", "", false, true), + Entry("FAILED -> DELETING -> skip", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_DELETING, "FAILED", "", true, false), + Entry("FAILED -> UNSPECIFIED -> skip", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_UNSPECIFIED, "FAILED", "", true, false), + + // --- From STOPPING --- + Entry("STOPPING -> RUNNING -> started.v1", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_RUNNING, "STOPPING", events.EventStarted, false, false), + Entry("STOPPING -> STOPPED -> skip", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STOPPED, "STOPPING", "", true, false), + Entry("STOPPING -> PAUSED -> skip", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_PAUSED, "STOPPING", "", true, false), + Entry("STOPPING -> FAILED -> skip", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_FAILED, "STOPPING", "", true, false), + Entry("STOPPING -> STOPPING -> skip (same-state)", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STOPPING, "STOPPING", "", true, false), + Entry("STOPPING -> STARTING -> transient", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STARTING, "STOPPING", "", false, true), + Entry("STOPPING -> DELETING -> skip", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_DELETING, "STOPPING", "", true, false), + Entry("STOPPING -> UNSPECIFIED -> skip", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_UNSPECIFIED, "STOPPING", "", true, false), + + // --- From STARTING --- + Entry("STARTING -> RUNNING -> started.v1", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_RUNNING, "STARTING", events.EventStarted, false, false), + Entry("STARTING -> STOPPED -> skip", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STOPPED, "STARTING", "", true, false), + Entry("STARTING -> PAUSED -> skip", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_PAUSED, "STARTING", "", true, false), + Entry("STARTING -> FAILED -> skip", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_FAILED, "STARTING", "", true, false), + Entry("STARTING -> STOPPING -> transient", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STOPPING, "STARTING", "", false, true), + Entry("STARTING -> STARTING -> skip (same-state)", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STARTING, "STARTING", "", true, false), + Entry("STARTING -> DELETING -> skip", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_DELETING, "STARTING", "", true, false), + Entry("STARTING -> UNSPECIFIED -> skip", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_UNSPECIFIED, "STARTING", "", true, false), + + // --- From DELETING --- + Entry("DELETING -> RUNNING -> started.v1", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_RUNNING, "DELETING", events.EventStarted, false, false), + Entry("DELETING -> STOPPED -> skip", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STOPPED, "DELETING", "", true, false), + Entry("DELETING -> PAUSED -> skip", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_PAUSED, "DELETING", "", true, false), + Entry("DELETING -> FAILED -> skip", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_FAILED, "DELETING", "", true, false), + Entry("DELETING -> STOPPING -> transient", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STOPPING, "DELETING", "", false, true), + Entry("DELETING -> STARTING -> transient", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STARTING, "DELETING", "", false, true), + Entry("DELETING -> DELETING -> skip (same-state)", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_DELETING, "DELETING", "", true, false), + Entry("DELETING -> UNSPECIFIED -> skip", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_UNSPECIFIED, "DELETING", "", true, false), + + // --- From UNSPECIFIED --- + Entry("UNSPECIFIED -> RUNNING -> started.v1", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_RUNNING, "UNSPECIFIED", events.EventStarted, false, false), + Entry("UNSPECIFIED -> STOPPED -> skip", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STOPPED, "UNSPECIFIED", "", true, false), + Entry("UNSPECIFIED -> PAUSED -> skip", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_PAUSED, "UNSPECIFIED", "", true, false), + Entry("UNSPECIFIED -> FAILED -> skip", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_FAILED, "UNSPECIFIED", "", true, false), + Entry("UNSPECIFIED -> STOPPING -> transient", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STOPPING, "UNSPECIFIED", "", false, true), + Entry("UNSPECIFIED -> STARTING -> transient", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STARTING, "UNSPECIFIED", "", false, true), + Entry("UNSPECIFIED -> DELETING -> skip", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_DELETING, "UNSPECIFIED", "", true, false), + Entry("UNSPECIFIED -> UNSPECIFIED -> skip (same-state)", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_UNSPECIFIED, "UNSPECIFIED", "", true, false), + ) + + It("returns error for unknown state (missing table entry)", func() { ci.Status.State = privatev1.ComputeInstanceState(9999) event := &privatev1.Event{ @@ -271,7 +256,7 @@ var _ = Describe("MapWatchEvent", func() { Expect(errors.Is(err, events.ErrTransientState)).To(BeFalse()) }) - It("maps RUNNING→STOPPED with duration to osac.resource.suspended.v1", func() { + It("maps RUNNING->STOPPED with duration to osac.resource.suspended.v1", func() { ci.Status.State = privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STOPPED event := &privatev1.Event{ @@ -287,14 +272,14 @@ var _ = Describe("MapWatchEvent", func() { } ce, err := mapEvent(event, stateCtx) Expect(err).NotTo(HaveOccurred()) - Expect(ce.Type()).To(Equal("osac.resource.suspended.v1")) + Expect(ce.Type()).To(Equal(events.EventSuspended)) var data map[string]any Expect(json.Unmarshal(ce.Data(), &data)).To(Succeed()) Expect(data["duration_seconds"]).To(BeNumerically("==", 7200.0)) }) - It("maps RUNNING→FAILED (prev=RUNNING) to osac.resource.suspended.v1", func() { + It("maps RUNNING->FAILED (prev=RUNNING) to osac.resource.suspended.v1", func() { ci.Status.State = privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_FAILED event := &privatev1.Event{ @@ -306,7 +291,7 @@ var _ = Describe("MapWatchEvent", func() { stateCtx := &events.StateContext{PreviousState: "RUNNING"} ce, err := mapEvent(event, stateCtx) Expect(err).NotTo(HaveOccurred()) - Expect(ce.Type()).To(Equal("osac.resource.suspended.v1")) + Expect(ce.Type()).To(Equal(events.EventSuspended)) }) It("includes previous_state and duration_seconds when state context provided", func() { @@ -332,6 +317,18 @@ var _ = Describe("MapWatchEvent", func() { Expect(data["duration_seconds"]).To(BeNumerically("==", 3600.5)) }) + It("maps OBJECT_CREATED to osac.resource.created.v1", func() { + event := &privatev1.Event{ + Id: "evt-1", + Type: privatev1.EventType_EVENT_TYPE_OBJECT_CREATED, + Payload: &privatev1.Event_ComputeInstance{ComputeInstance: ci}, + } + + ce, err := mapEvent(event, &events.StateContext{}) + Expect(err).NotTo(HaveOccurred()) + Expect(ce.Type()).To(Equal(events.EventCreated)) + }) + It("maps OBJECT_DELETED to osac.resource.deleted.v1", func() { ci.Metadata.DeletionTimestamp = timestamppb.Now() @@ -343,7 +340,7 @@ var _ = Describe("MapWatchEvent", func() { ce, err := mapEvent(event, &events.StateContext{}) Expect(err).NotTo(HaveOccurred()) - Expect(ce.Type()).To(Equal("osac.resource.deleted.v1")) + Expect(ce.Type()).To(Equal(events.EventDeleted)) }) }) @@ -768,7 +765,7 @@ var _ = Describe("MapWatchEvent", func() { _, err := mapEvent(event, &events.StateContext{}) Expect(err).To(HaveOccurred()) - Expect(err).To(MatchError(ContainSubstring("no creation_timestamp"))) + Expect(err).To(MatchError(ContainSubstring("no timestamp for event type"))) Expect(errors.Is(err, events.ErrDataQuality)).To(BeTrue()) }) @@ -798,7 +795,7 @@ var _ = Describe("MapWatchEvent", func() { _, err := mapEvent(event, &events.StateContext{}) Expect(err).To(HaveOccurred()) - Expect(err).To(MatchError(ContainSubstring("no state_transition_time"))) + Expect(err).To(MatchError(ContainSubstring("no timestamp for event type"))) Expect(errors.Is(err, events.ErrDataQuality)).To(BeTrue()) }) @@ -828,7 +825,7 @@ var _ = Describe("MapWatchEvent", func() { _, err := mapEvent(event, &events.StateContext{}) Expect(err).To(HaveOccurred()) - Expect(err).To(MatchError(ContainSubstring("no deletion_timestamp"))) + Expect(err).To(MatchError(ContainSubstring("no timestamp for event type"))) Expect(errors.Is(err, events.ErrDataQuality)).To(BeTrue()) }) }) @@ -873,3 +870,49 @@ var _ = Describe("DimensionsEqual", func() { Expect(events.DimensionsEqual(nil, nil)).To(BeTrue()) }) }) + +var _ = Describe("VMaaS transition table completeness", func() { + stateProtoMap := map[string]privatev1.ComputeInstanceState{ + "RUNNING": privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_RUNNING, + "STOPPED": privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STOPPED, + "PAUSED": privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_PAUSED, + "FAILED": privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_FAILED, + "STOPPING": privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STOPPING, + "STARTING": privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STARTING, + "DELETING": privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_DELETING, + "UNSPECIFIED": privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_UNSPECIFIED, + } + + It("covers every (from, to) state pair from all proto states plus empty initial", func() { + fromStates := []string{"", "RUNNING", "STOPPED", "PAUSED", "FAILED", "STOPPING", "STARTING", "DELETING", "UNSPECIFIED"} + toStates := []string{"RUNNING", "STOPPED", "PAUSED", "FAILED", "STOPPING", "STARTING", "DELETING", "UNSPECIFIED"} + + for _, from := range fromStates { + for _, to := range toStates { + ci := &privatev1.ComputeInstance{ + Id: "ci-completeness", + Metadata: &privatev1.Metadata{Tenant: "t", CreationTimestamp: timestamppb.Now()}, + Spec: &privatev1.ComputeInstanceSpec{}, + Status: &privatev1.ComputeInstanceStatus{ + State: stateProtoMap[to], + StateTransitionTime: timestamppb.Now(), + }, + } + + event := &privatev1.Event{ + Id: "evt-completeness", + Type: privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, + Payload: &privatev1.Event_ComputeInstance{ComputeInstance: ci}, + } + + stateCtx := &events.StateContext{PreviousState: from} + _, err := mapEvent(event, stateCtx) + + Expect(err == nil || + errors.Is(err, events.ErrSkipTransition) || + errors.Is(err, events.ErrTransientState)).To(BeTrue(), + "transition %s -> %s returned unexpected error: %v", from, to, err) + } + } + }) +}) diff --git a/osac-metering/metering-service/internal/events/transitions.go b/osac-metering/metering-service/internal/events/transitions.go index 64745d850..7be08ba10 100644 --- a/osac-metering/metering-service/internal/events/transitions.go +++ b/osac-metering/metering-service/internal/events/transitions.go @@ -12,6 +12,12 @@ package events import ( "errors" "fmt" + "time" + + cloudevents "github.com/cloudevents/sdk-go/v2" + "google.golang.org/protobuf/types/known/timestamppb" + + privatev1 "github.com/osac-project/osac-metering/internal/api/osac/private/v1" ) var ( @@ -19,8 +25,28 @@ var ( ErrSkipTransition = errors.New("no billing boundary: skip event, check for scaling") ) +// CloudEvent type constants. +const ( + EventCreated = "osac.resource.created.v1" + EventStarted = "osac.resource.started.v1" + EventResumed = "osac.resource.resumed.v1" + EventSuspended = "osac.resource.suspended.v1" + EventDeleted = "osac.resource.deleted.v1" + EventUpdated = "osac.resource.updated.v1" + EventHeartbeat = "osac.resource.heartbeat.v1" + EventCorrection = "osac.resource.correction.v1" +) + +// Resource type constants. +const ( + ResourceTypeComputeInstance = "compute_instance" + ResourceTypeClusterOrder = "cluster_order" +) + +// StateEmpty is the empty previous state for initial transitions. +const StateEmpty = "" + // TransitionKey identifies a state transition by (previous, current) state. -// Use "*" as From to match any previous state (wildcard). type TransitionKey struct { From string To string @@ -38,15 +64,11 @@ type TransitionResult struct { type TransitionTable map[TransitionKey]TransitionResult // resolveTransition looks up the event type for a state transition. -// Exact (from, to) match takes priority over wildcard (*, to). -// Missing entry = error (fail fast on unknown transitions). +// Exact (from, to) match only — missing entry = error (fail fast on unknown transitions). func resolveTransition(table TransitionTable, from, to string) (string, error) { if result, ok := table[TransitionKey{from, to}]; ok { return applyResult(result) } - if result, ok := table[TransitionKey{"*", to}]; ok { - return applyResult(result) - } return "", fmt.Errorf("unexpected state transition: %s -> %s", from, to) } @@ -59,3 +81,70 @@ func applyResult(r TransitionResult) (string, error) { } return r.EventType, nil } + +// fixedEventTypes maps event types that always produce the same CloudEvent type +// regardless of state transition. +var fixedEventTypes = map[privatev1.EventType]string{ + privatev1.EventType_EVENT_TYPE_OBJECT_CREATED: EventCreated, + privatev1.EventType_EVENT_TYPE_OBJECT_DELETED: EventDeleted, +} + +// ResolveCloudEventType returns the CloudEvent type for a given proto event type +// and state transition. CREATED and DELETED are fixed; UPDATED delegates to the +// transition table. +func ResolveCloudEventType(table TransitionTable, eventType privatev1.EventType, previousState, currentState string) (string, error) { + if ceType, ok := fixedEventTypes[eventType]; ok { + return ceType, nil + } + if eventType == privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED { + return resolveTransition(table, previousState, currentState) + } + return "", fmt.Errorf("unsupported event type: %v", eventType) +} + +// ResolveTransitionTime selects the appropriate timestamp for a given event type. +func ResolveTransitionTime(eventType privatev1.EventType, creation, deletion, stateTransition *timestamppb.Timestamp, resourceID string) (time.Time, error) { + timestamps := map[privatev1.EventType]*timestamppb.Timestamp{ + privatev1.EventType_EVENT_TYPE_OBJECT_CREATED: creation, + privatev1.EventType_EVENT_TYPE_OBJECT_DELETED: deletion, + privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED: stateTransition, + } + + ts, ok := timestamps[eventType] + if !ok { + return time.Time{}, fmt.Errorf("unsupported event type for timestamp: %v", eventType) + } + if ts == nil { + return time.Time{}, fmt.Errorf("%w: resource %s has no timestamp for event type %v", ErrDataQuality, resourceID, eventType) + } + return ts.AsTime(), nil +} + +// EventBuilder creates a CloudEvent from billing dimensions and an event ID. +type EventBuilder func(dims map[string]any, eventID string) (cloudevents.Event, error) + +// EventDecomposer produces one or more CloudEvents from billing dimensions. +type EventDecomposer func(dims map[string]any, baseID string, buildFn EventBuilder) ([]cloudevents.Event, error) + +func singleEvent(dims map[string]any, baseID string, buildFn EventBuilder) ([]cloudevents.Event, error) { + ce, err := buildFn(dims, baseID) + if err != nil { + return nil, err + } + return []cloudevents.Event{ce}, nil +} + +var resourceDecomposers = map[string]EventDecomposer{ + ResourceTypeComputeInstance: singleEvent, + ResourceTypeClusterOrder: DecomposeClusterEvents, +} + +// BuildResourceEvents dispatches event building to the correct decomposer +// for the given resource type. +func BuildResourceEvents(resourceType string, dims map[string]any, baseID string, buildFn EventBuilder) ([]cloudevents.Event, error) { + decomposer, ok := resourceDecomposers[resourceType] + if !ok { + return nil, fmt.Errorf("unknown resource type for event decomposition: %s", resourceType) + } + return decomposer(dims, baseID, buildFn) +} diff --git a/osac-metering/metering-service/internal/events/transitions_test.go b/osac-metering/metering-service/internal/events/transitions_test.go new file mode 100644 index 000000000..48a89d5e2 --- /dev/null +++ b/osac-metering/metering-service/internal/events/transitions_test.go @@ -0,0 +1,257 @@ +/* +Copyright (c) 2026 Red Hat, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ + +package events_test + +import ( + "errors" + "fmt" + "time" + + cloudevents "github.com/cloudevents/sdk-go/v2" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "google.golang.org/protobuf/types/known/timestamppb" + + privatev1 "github.com/osac-project/osac-metering/internal/api/osac/private/v1" + "github.com/osac-project/osac-metering/internal/events" +) + +var _ = Describe("ResolveCloudEventType", func() { + It("returns created.v1 for CREATED event type", func() { + ceType, err := events.ResolveCloudEventType( + events.TransitionTable{}, + privatev1.EventType_EVENT_TYPE_OBJECT_CREATED, + "", "", + ) + Expect(err).NotTo(HaveOccurred()) + Expect(ceType).To(Equal(events.EventCreated)) + }) + + It("returns deleted.v1 for DELETED event type", func() { + ceType, err := events.ResolveCloudEventType( + events.TransitionTable{}, + privatev1.EventType_EVENT_TYPE_OBJECT_DELETED, + "", "", + ) + Expect(err).NotTo(HaveOccurred()) + Expect(ceType).To(Equal(events.EventDeleted)) + }) + + It("delegates to transition table for UPDATED event type", func() { + table := events.TransitionTable{ + {From: "A", To: "B"}: {EventType: events.EventStarted}, + } + ceType, err := events.ResolveCloudEventType( + table, + privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, + "A", "B", + ) + Expect(err).NotTo(HaveOccurred()) + Expect(ceType).To(Equal(events.EventStarted)) + }) + + It("returns error for UPDATED with missing table entry", func() { + table := events.TransitionTable{ + {From: "A", To: "B"}: {EventType: events.EventStarted}, + } + _, err := events.ResolveCloudEventType( + table, + privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, + "X", "Y", + ) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("unexpected state transition")) + }) + + It("returns error for unknown event type", func() { + _, err := events.ResolveCloudEventType( + events.TransitionTable{}, + privatev1.EventType(9999), + "", "", + ) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("unsupported event type")) + }) +}) + +var _ = Describe("ResolveTransitionTime", func() { + var ( + creationTS *timestamppb.Timestamp + deletionTS *timestamppb.Timestamp + stateTransitionTS *timestamppb.Timestamp + ) + + BeforeEach(func() { + creationTS = timestamppb.New(time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC)) + deletionTS = timestamppb.New(time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC)) + stateTransitionTS = timestamppb.New(time.Date(2026, 7, 1, 11, 30, 0, 0, time.UTC)) + }) + + It("returns creation timestamp for CREATED event type", func() { + t, err := events.ResolveTransitionTime( + privatev1.EventType_EVENT_TYPE_OBJECT_CREATED, + creationTS, deletionTS, stateTransitionTS, "res-1", + ) + Expect(err).NotTo(HaveOccurred()) + Expect(t).To(Equal(creationTS.AsTime())) + }) + + It("returns deletion timestamp for DELETED event type", func() { + t, err := events.ResolveTransitionTime( + privatev1.EventType_EVENT_TYPE_OBJECT_DELETED, + creationTS, deletionTS, stateTransitionTS, "res-1", + ) + Expect(err).NotTo(HaveOccurred()) + Expect(t).To(Equal(deletionTS.AsTime())) + }) + + It("returns state transition timestamp for UPDATED event type", func() { + t, err := events.ResolveTransitionTime( + privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, + creationTS, deletionTS, stateTransitionTS, "res-1", + ) + Expect(err).NotTo(HaveOccurred()) + Expect(t).To(Equal(stateTransitionTS.AsTime())) + }) + + It("returns error for unknown event type", func() { + _, err := events.ResolveTransitionTime( + privatev1.EventType(9999), + creationTS, deletionTS, stateTransitionTS, "res-1", + ) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("unsupported event type for timestamp")) + }) + + It("returns ErrDataQuality when creation timestamp is nil for CREATED", func() { + _, err := events.ResolveTransitionTime( + privatev1.EventType_EVENT_TYPE_OBJECT_CREATED, + nil, deletionTS, stateTransitionTS, "res-1", + ) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, events.ErrDataQuality)).To(BeTrue()) + }) + + It("returns ErrDataQuality when deletion timestamp is nil for DELETED", func() { + _, err := events.ResolveTransitionTime( + privatev1.EventType_EVENT_TYPE_OBJECT_DELETED, + creationTS, nil, stateTransitionTS, "res-1", + ) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, events.ErrDataQuality)).To(BeTrue()) + }) + + It("returns ErrDataQuality when state transition timestamp is nil for UPDATED", func() { + _, err := events.ResolveTransitionTime( + privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, + creationTS, deletionTS, nil, "res-1", + ) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, events.ErrDataQuality)).To(BeTrue()) + }) +}) + +var _ = Describe("BuildResourceEvents", func() { + simpleBuildFn := func(dims map[string]any, eventID string) (cloudevents.Event, error) { + ce := cloudevents.NewEvent() + ce.SetID(eventID) + if err := ce.SetData(cloudevents.ApplicationJSON, dims); err != nil { + return cloudevents.Event{}, fmt.Errorf("setting data: %w", err) + } + return ce, nil + } + + It("returns a single event for compute_instance", func() { + dims := map[string]any{"instance_type": "large-gpu"} + result, err := events.BuildResourceEvents( + events.ResourceTypeComputeInstance, dims, "evt-ci-1", simpleBuildFn, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID()).To(Equal("evt-ci-1")) + }) + + It("decomposes cluster_order into per-component events", func() { + dims := map[string]any{ + "cluster_template": "ocp-ci-small", + "components": []any{ + map[string]any{ + "node_set": "_control_plane", + "component": "control_plane", + "host_type": "_control_plane", + "node_count": int32(1), + }, + map[string]any{ + "node_set": "gpu-workers", + "component": "worker", + "host_type": "gpu-h100", + "node_count": int32(2), + }, + }, + } + + result, err := events.BuildResourceEvents( + events.ResourceTypeClusterOrder, dims, "evt-cl-1", simpleBuildFn, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(HaveLen(2)) + }) + + It("returns error for unknown resource type", func() { + _, err := events.BuildResourceEvents( + "unknown_resource", map[string]any{}, "evt-1", simpleBuildFn, + ) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("unknown resource type for event decomposition")) + }) +}) + +var _ = Describe("resolveTransition (indirect via ResolveCloudEventType)", func() { + It("returns correct event type for an exact table match", func() { + table := events.TransitionTable{ + {From: "STOPPED", To: "RUNNING"}: {EventType: events.EventResumed}, + } + ceType, err := events.ResolveCloudEventType( + table, + privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, + "STOPPED", "RUNNING", + ) + Expect(err).NotTo(HaveOccurred()) + Expect(ceType).To(Equal(events.EventResumed)) + }) + + It("returns error for a missing table entry", func() { + table := events.TransitionTable{ + {From: "STOPPED", To: "RUNNING"}: {EventType: events.EventResumed}, + } + _, err := events.ResolveCloudEventType( + table, + privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, + "FAILED", "RUNNING", + ) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("unexpected state transition")) + }) + + It("does not fall back to wildcard-like partial matches", func() { + // Table has only {"A", "B"} entry. If wildcards existed, + // {"C", "B"} might match a wildcard on From. Verify it does not. + table := events.TransitionTable{ + {From: "A", To: "B"}: {EventType: events.EventStarted}, + } + _, err := events.ResolveCloudEventType( + table, + privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, + "C", "B", + ) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("unexpected state transition")) + }) +}) diff --git a/osac-metering/metering-service/internal/heartbeat/generator.go b/osac-metering/metering-service/internal/heartbeat/generator.go index 5e5ea1643..2800811af 100644 --- a/osac-metering/metering-service/internal/heartbeat/generator.go +++ b/osac-metering/metering-service/internal/heartbeat/generator.go @@ -129,21 +129,14 @@ func (g *Generator) buildHeartbeatEvents(state *projection.ResourceState, now ti return g.buildHeartbeatEvent(state, eventID, dims, now) } - if state.ResourceType == "cluster_order" { - return events.DecomposeClusterEvents(state.BillingDimensions, uuid.NewString(), buildFn) - } - ce, err := buildFn(state.BillingDimensions, uuid.NewString()) - if err != nil { - return nil, err - } - return []cloudevents.Event{ce}, nil + return events.BuildResourceEvents(state.ResourceType, state.BillingDimensions, uuid.NewString(), buildFn) } func (g *Generator) buildHeartbeatEvent(state *projection.ResourceState, eventID string, dims map[string]any, now time.Time) (cloudevents.Event, error) { ce := cloudevents.NewEvent() ce.SetID(eventID) ce.SetSource("osac-metering") - ce.SetType("osac.resource.heartbeat.v1") + ce.SetType(events.EventHeartbeat) ce.SetTime(now) events.SetOSACExtensions(&ce, state.ResourceID, state.ResourceType, state.TenantID, state.ProjectID) diff --git a/osac-metering/metering-service/internal/heartbeat/generator_test.go b/osac-metering/metering-service/internal/heartbeat/generator_test.go index 256aeef6e..c610927f9 100644 --- a/osac-metering/metering-service/internal/heartbeat/generator_test.go +++ b/osac-metering/metering-service/internal/heartbeat/generator_test.go @@ -12,6 +12,7 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/osac-project/osac-metering/internal/events" "github.com/osac-project/osac-metering/internal/heartbeat" "github.com/osac-project/osac-metering/internal/projection" ) @@ -78,7 +79,7 @@ func makeBillableState(id string) projection.ResourceState { now := time.Now().UTC().Truncate(time.Microsecond) return projection.ResourceState{ ResourceID: id, - ResourceType: "compute_instance", + ResourceType: events.ResourceTypeComputeInstance, TenantID: "tenant-1", ProjectID: "project-1", CurrentState: "RUNNING", @@ -111,7 +112,7 @@ var _ = Describe("Generator", func() { pub.mu.Lock() defer pub.mu.Unlock() Expect(len(pub.published)).To(BeNumerically(">=", 2)) - Expect(pub.published[0].Type()).To(Equal("osac.resource.heartbeat.v1")) + Expect(pub.published[0].Type()).To(Equal(events.EventHeartbeat)) }) It("stops on context cancellation", func() { @@ -261,7 +262,7 @@ var _ = Describe("Generator", func() { now := time.Now().UTC().Truncate(time.Microsecond) return projection.ResourceState{ ResourceID: id, - ResourceType: "cluster_order", + ResourceType: events.ResourceTypeClusterOrder, TenantID: "tenant-1", ProjectID: "project-1", CurrentState: "READY", @@ -297,7 +298,7 @@ var _ = Describe("Generator", func() { components := map[string]bool{} for _, e := range pub.published { - Expect(e.Type()).To(Equal("osac.resource.heartbeat.v1")) + Expect(e.Type()).To(Equal(events.EventHeartbeat)) var data map[string]any Expect(json.Unmarshal(e.Data(), &data)).To(Succeed()) bd := data["billing_dimensions"].(map[string]any) diff --git a/osac-metering/metering-service/internal/reconciliation/correction.go b/osac-metering/metering-service/internal/reconciliation/correction.go index 2cf98f233..02cadadbb 100644 --- a/osac-metering/metering-service/internal/reconciliation/correction.go +++ b/osac-metering/metering-service/internal/reconciliation/correction.go @@ -50,19 +50,19 @@ type correctionData struct { SchemaVersion string `json:"schema_version"` } +var correctionDescriptions = map[CorrectionReason]string{ + MissedCreation: "Resource found in fulfillment-service but missing from metering projection", + StateDrift: "Resource state in fulfillment-service differs from metering projection", + BillingDimensionsDrift: "Billing dimensions in fulfillment-service differ from metering projection", + MissedDeletion: "Resource found in metering projection but missing from fulfillment-service", +} + func correctionDescription(reason CorrectionReason) (string, error) { - switch reason { - case MissedCreation: - return "Resource found in fulfillment-service but missing from metering projection", nil - case StateDrift: - return "Resource state in fulfillment-service differs from metering projection", nil - case BillingDimensionsDrift: - return "Billing dimensions in fulfillment-service differ from metering projection", nil - case MissedDeletion: - return "Resource found in metering projection but missing from fulfillment-service", nil - default: + desc, ok := correctionDescriptions[reason] + if !ok { return "", fmt.Errorf("unknown correction reason: %s", reason) } + return desc, nil } func buildCorrectionEvents( @@ -84,14 +84,7 @@ func buildCorrectionEvents( return ce, nil } - if resourceType == "cluster_order" { - return events.DecomposeClusterEvents(billingDimensions, baseID, buildFn) - } - ce, err := buildFn(billingDimensions, baseID) - if err != nil { - return nil, err - } - return []cloudevents.Event{ce}, nil + return events.BuildResourceEvents(resourceType, billingDimensions, baseID, buildFn) } func buildCorrectionEvent( @@ -105,7 +98,7 @@ func buildCorrectionEvent( ce := cloudevents.NewEvent() ce.SetID(uuid.NewString()) ce.SetSource("osac-metering/reconciler") - ce.SetType("osac.resource.correction.v1") + ce.SetType(events.EventCorrection) ce.SetTime(now) events.SetOSACExtensions(&ce, resourceID, resourceType, tenantID, projectID) diff --git a/osac-metering/metering-service/internal/reconciliation/correction_internal_test.go b/osac-metering/metering-service/internal/reconciliation/correction_internal_test.go new file mode 100644 index 000000000..0f963cfae --- /dev/null +++ b/osac-metering/metering-service/internal/reconciliation/correction_internal_test.go @@ -0,0 +1,49 @@ +/* +Copyright (c) 2026 Red Hat, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ + +package reconciliation + +import ( + "testing" +) + +func TestCorrectionDescription(t *testing.T) { + tests := []struct { + reason CorrectionReason + expected string + }{ + {MissedCreation, "Resource found in fulfillment-service but missing from metering projection"}, + {StateDrift, "Resource state in fulfillment-service differs from metering projection"}, + {BillingDimensionsDrift, "Billing dimensions in fulfillment-service differ from metering projection"}, + {MissedDeletion, "Resource found in metering projection but missing from fulfillment-service"}, + } + + for _, tc := range tests { + t.Run(string(tc.reason), func(t *testing.T) { + desc, err := correctionDescription(tc.reason) + if err != nil { + t.Fatalf("unexpected error for reason %s: %v", tc.reason, err) + } + if desc != tc.expected { + t.Errorf("expected %q, got %q", tc.expected, desc) + } + }) + } +} + +func TestCorrectionDescriptionUnknownReason(t *testing.T) { + _, err := correctionDescription("unknown_reason") + if err == nil { + t.Fatal("expected error for unknown correction reason, got nil") + } + expected := "unknown correction reason: unknown_reason" + if err.Error() != expected { + t.Errorf("expected error %q, got %q", expected, err.Error()) + } +} diff --git a/osac-metering/metering-service/internal/reconciliation/reconciler.go b/osac-metering/metering-service/internal/reconciliation/reconciler.go index db0514fd2..ef0510287 100644 --- a/osac-metering/metering-service/internal/reconciliation/reconciler.go +++ b/osac-metering/metering-service/internal/reconciliation/reconciler.go @@ -48,17 +48,17 @@ var ( }) ) -type ComputeInstanceLister interface { +type ComputeInstancesClient interface { List(ctx context.Context, in *privatev1.ComputeInstancesListRequest, opts ...grpc.CallOption) (*privatev1.ComputeInstancesListResponse, error) } -type ClusterLister interface { +type ClustersClient interface { List(ctx context.Context, in *privatev1.ClustersListRequest, opts ...grpc.CallOption) (*privatev1.ClustersListResponse, error) } type Reconciler struct { - computeClient ComputeInstanceLister - clusterClient ClusterLister + computeClient ComputeInstancesClient + clusterClient ClustersClient store projection.Store publisher kafkapub.EventPublisher logger logr.Logger @@ -66,8 +66,8 @@ type Reconciler struct { } func NewReconciler( - computeClient ComputeInstanceLister, - clusterClient ClusterLister, + computeClient ComputeInstancesClient, + clusterClient ClustersClient, store projection.Store, publisher kafkapub.EventPublisher, logger logr.Logger, @@ -267,7 +267,7 @@ func (r *Reconciler) reconcileMissedDeletions(ctx context.Context, fulfillmentSt for id, ps := range projMap { if _, exists := fulfillmentState[id]; !exists { - if ps.ResourceType == "cluster_order" && r.clusterClient == nil { + if ps.ResourceType == events.ResourceTypeClusterOrder && r.clusterClient == nil { if !clusterSkipLogged { r.logger.Info("skipping cluster_order missed deletion checks, no cluster client configured") clusterSkipLogged = true @@ -360,15 +360,17 @@ type fulfillmentResource struct { billingDimensions map[string]any } +var billabilityCheckers = map[string]func(string) bool{ + events.ResourceTypeComputeInstance: events.IsBillableState, + events.ResourceTypeClusterOrder: events.IsClusterBillableState, +} + func isBillableForType(resourceType, state string) (bool, error) { - switch resourceType { - case "compute_instance": - return events.IsBillableState(state), nil - case "cluster_order": - return events.IsClusterBillableState(state), nil - default: + checker, ok := billabilityCheckers[resourceType] + if !ok { return false, fmt.Errorf("unknown resource type: %s", resourceType) } + return checker(state), nil } func (r *Reconciler) loadFulfillmentState(ctx context.Context) (map[string]fulfillmentResource, error) { @@ -413,7 +415,7 @@ func (r *Reconciler) loadComputeInstances(ctx context.Context, result map[string version = md.GetVersion() } result[ci.GetId()] = fulfillmentResource{ - resourceType: "compute_instance", + resourceType: events.ResourceTypeComputeInstance, state: state, version: version, tenantID: tenantID, @@ -457,7 +459,7 @@ func (r *Reconciler) loadClusters(ctx context.Context, result map[string]fulfill version = md.GetVersion() } result[cl.GetId()] = fulfillmentResource{ - resourceType: "cluster_order", + resourceType: events.ResourceTypeClusterOrder, state: state, version: version, tenantID: tenantID, @@ -480,21 +482,14 @@ func buildSyntheticHeartbeats(ps projection.ResourceState, now time.Time) ([]clo return buildSingleSyntheticHeartbeat(ps, dims, eventID, now) } - if ps.ResourceType == "cluster_order" { - return events.DecomposeClusterEvents(ps.BillingDimensions, baseID, buildFn) - } - ce, err := buildFn(ps.BillingDimensions, baseID) - if err != nil { - return nil, err - } - return []cloudevents.Event{ce}, nil + return events.BuildResourceEvents(ps.ResourceType, ps.BillingDimensions, baseID, buildFn) } func buildSingleSyntheticHeartbeat(ps projection.ResourceState, billingDims map[string]any, eventID string, now time.Time) (cloudevents.Event, error) { ce := cloudevents.NewEvent() ce.SetID(eventID) ce.SetSource("osac-metering/reconciler") - ce.SetType("osac.resource.heartbeat.v1") + ce.SetType(events.EventHeartbeat) ce.SetTime(now) events.SetOSACExtensions(&ce, ps.ResourceID, ps.ResourceType, ps.TenantID, ps.ProjectID) diff --git a/osac-metering/metering-service/internal/reconciliation/reconciler_test.go b/osac-metering/metering-service/internal/reconciliation/reconciler_test.go index feb7a402d..b5c7944c1 100644 --- a/osac-metering/metering-service/internal/reconciliation/reconciler_test.go +++ b/osac-metering/metering-service/internal/reconciliation/reconciler_test.go @@ -14,6 +14,7 @@ import ( "google.golang.org/grpc" privatev1 "github.com/osac-project/osac-metering/internal/api/osac/private/v1" + "github.com/osac-project/osac-metering/internal/events" "github.com/osac-project/osac-metering/internal/projection" "github.com/osac-project/osac-metering/internal/reconciliation" ) @@ -190,7 +191,7 @@ var _ = Describe("Reconciler", func() { defer pub.mu.Unlock() var correctionFound bool for _, e := range pub.published { - if e.Type() == "osac.resource.correction.v1" { + if e.Type() == events.EventCorrection { var data map[string]any Expect(json.Unmarshal(e.Data(), &data)).To(Succeed()) Expect(data["reason"]).To(Equal("missed_creation")) @@ -210,7 +211,7 @@ var _ = Describe("Reconciler", func() { store := newMockStore() store.states["vm-gone"] = projection.ResourceState{ ResourceID: "vm-gone", - ResourceType: "compute_instance", + ResourceType: events.ResourceTypeComputeInstance, TenantID: "tenant-1", CurrentState: "RUNNING", } @@ -240,7 +241,7 @@ var _ = Describe("Reconciler", func() { store := newMockStore() store.states["vm-drift"] = projection.ResourceState{ ResourceID: "vm-drift", - ResourceType: "compute_instance", + ResourceType: events.ResourceTypeComputeInstance, TenantID: "tenant-1", CurrentState: "RUNNING", FulfillmentVersion: 3, @@ -269,7 +270,7 @@ var _ = Describe("Reconciler", func() { store := newMockStore() store.states["res-drift"] = projection.ResourceState{ ResourceID: "res-drift", - ResourceType: "compute_instance", + ResourceType: events.ResourceTypeComputeInstance, TenantID: "tenant-1", CurrentState: "STOPPED", IsBillable: false, @@ -298,7 +299,7 @@ var _ = Describe("Reconciler", func() { store := newMockStore() store.states["res-stale"] = projection.ResourceState{ ResourceID: "res-stale", - ResourceType: "compute_instance", + ResourceType: events.ResourceTypeComputeInstance, TenantID: "tenant-1", CurrentState: "RUNNING", IsBillable: true, @@ -314,7 +315,7 @@ var _ = Describe("Reconciler", func() { defer pub.mu.Unlock() found := false for _, e := range pub.published { - if e.Type() == "osac.resource.heartbeat.v1" { + if e.Type() == events.EventHeartbeat { found = true break } @@ -352,7 +353,7 @@ var _ = Describe("Reconciler", func() { store := newMockStore() store.states["vm-ok"] = projection.ResourceState{ ResourceID: "vm-ok", - ResourceType: "compute_instance", + ResourceType: events.ResourceTypeComputeInstance, TenantID: "tenant-1", CurrentState: "RUNNING", BillingDimensions: map[string]any{ @@ -423,7 +424,7 @@ var _ = Describe("Reconciler", func() { store := newMockStore() store.states["vm-dims-drift"] = projection.ResourceState{ ResourceID: "vm-dims-drift", - ResourceType: "compute_instance", + ResourceType: events.ResourceTypeComputeInstance, TenantID: "tenant-1", CurrentState: "RUNNING", IsBillable: true, @@ -439,7 +440,7 @@ var _ = Describe("Reconciler", func() { defer pub.mu.Unlock() var found bool for _, e := range pub.published { - if e.Type() == "osac.resource.correction.v1" { + if e.Type() == events.EventCorrection { var data map[string]any Expect(json.Unmarshal(e.Data(), &data)).To(Succeed()) if data["reason"] == "billing_dimensions_drift" { @@ -477,7 +478,7 @@ var _ = Describe("Reconciler", func() { store := newMockStore() store.states["vm-keep-billable"] = projection.ResourceState{ ResourceID: "vm-keep-billable", - ResourceType: "compute_instance", + ResourceType: events.ResourceTypeComputeInstance, TenantID: "tenant-1", CurrentState: "RUNNING", IsBillable: true, @@ -507,7 +508,7 @@ var _ = Describe("Reconciler", func() { store := newMockStore() store.states["vm-version-advance"] = projection.ResourceState{ ResourceID: "vm-version-advance", - ResourceType: "compute_instance", + ResourceType: events.ResourceTypeComputeInstance, TenantID: "tenant-1", CurrentState: "RUNNING", FulfillmentVersion: 5, @@ -555,7 +556,7 @@ var _ = Describe("Reconciler", func() { store := newMockStore() store.states["vm-match"] = projection.ResourceState{ ResourceID: "vm-match", - ResourceType: "compute_instance", + ResourceType: events.ResourceTypeComputeInstance, TenantID: "tenant-1", CurrentState: "RUNNING", BillingDimensions: map[string]any{ @@ -584,7 +585,7 @@ var _ = Describe("Reconciler", func() { store := newMockStore() store.states["vm-stale"] = projection.ResourceState{ ResourceID: "vm-stale", - ResourceType: "compute_instance", + ResourceType: events.ResourceTypeComputeInstance, TenantID: "tenant-1", CurrentState: "RUNNING", FulfillmentVersion: 3, @@ -623,7 +624,7 @@ var _ = Describe("Reconciler", func() { defer pub.mu.Unlock() var found bool for _, e := range pub.published { - if e.Type() == "osac.resource.correction.v1" { + if e.Type() == events.EventCorrection { Expect(e.Extensions()["osacresourceid"]).To(Equal("vm-new")) found = true break @@ -690,12 +691,12 @@ var _ = Describe("Reconciler", func() { defer pub.mu.Unlock() correctionCount := 0 for _, e := range pub.published { - if e.Type() == "osac.resource.correction.v1" { + if e.Type() == events.EventCorrection { correctionCount++ var data map[string]any Expect(json.Unmarshal(e.Data(), &data)).To(Succeed()) Expect(data["reason"]).To(Equal("missed_creation")) - Expect(data["resource_type"]).To(Equal("cluster_order")) + Expect(data["resource_type"]).To(Equal(events.ResourceTypeClusterOrder)) bd := data["billing_dimensions"].(map[string]any) Expect(bd).To(HaveKey("component")) Expect(bd).To(HaveKey("host_type")) @@ -708,7 +709,7 @@ var _ = Describe("Reconciler", func() { store.mu.Lock() defer store.mu.Unlock() Expect(store.states).To(HaveKey("cl-missed")) - Expect(store.states["cl-missed"].ResourceType).To(Equal("cluster_order")) + Expect(store.states["cl-missed"].ResourceType).To(Equal(events.ResourceTypeClusterOrder)) Expect(store.states["cl-missed"].IsBillable).To(BeTrue()) }) @@ -723,7 +724,7 @@ var _ = Describe("Reconciler", func() { now := time.Now().UTC().Truncate(time.Microsecond) store.states["cl-drift"] = projection.ResourceState{ ResourceID: "cl-drift", - ResourceType: "cluster_order", + ResourceType: events.ResourceTypeClusterOrder, TenantID: "tenant-1", CurrentState: "READY", IsBillable: true, @@ -748,7 +749,7 @@ var _ = Describe("Reconciler", func() { defer pub.mu.Unlock() driftCount := 0 for _, e := range pub.published { - if e.Type() == "osac.resource.correction.v1" { + if e.Type() == events.EventCorrection { var data map[string]any Expect(json.Unmarshal(e.Data(), &data)).To(Succeed()) if data["reason"] == "state_drift" { @@ -771,7 +772,7 @@ var _ = Describe("Reconciler", func() { now := time.Now().UTC().Truncate(time.Microsecond) store.states["cl-gone"] = projection.ResourceState{ ResourceID: "cl-gone", - ResourceType: "cluster_order", + ResourceType: events.ResourceTypeClusterOrder, TenantID: "tenant-1", CurrentState: "READY", IsBillable: true, @@ -794,7 +795,7 @@ var _ = Describe("Reconciler", func() { defer pub.mu.Unlock() deletionCount := 0 for _, e := range pub.published { - if e.Type() == "osac.resource.correction.v1" { + if e.Type() == events.EventCorrection { var data map[string]any Expect(json.Unmarshal(e.Data(), &data)).To(Succeed()) if data["reason"] == "missed_deletion" { @@ -818,7 +819,7 @@ var _ = Describe("Reconciler", func() { now := time.Now().UTC().Truncate(time.Microsecond) store.states["cl-safe"] = projection.ResourceState{ ResourceID: "cl-safe", - ResourceType: "cluster_order", + ResourceType: events.ResourceTypeClusterOrder, TenantID: "tenant-1", CurrentState: "READY", IsBillable: true, @@ -835,7 +836,7 @@ var _ = Describe("Reconciler", func() { pub.mu.Lock() defer pub.mu.Unlock() for _, e := range pub.published { - Expect(e.Type()).ToNot(Equal("osac.resource.correction.v1")) + Expect(e.Type()).ToNot(Equal(events.EventCorrection)) } store.mu.Lock() @@ -874,7 +875,7 @@ var _ = Describe("Reconciler", func() { now := time.Now().Add(-5 * time.Minute).UTC().Truncate(time.Microsecond) store.states["cl-hb"] = projection.ResourceState{ ResourceID: "cl-hb", - ResourceType: "cluster_order", + ResourceType: events.ResourceTypeClusterOrder, TenantID: "tenant-1", CurrentState: "READY", IsBillable: true, @@ -900,7 +901,7 @@ var _ = Describe("Reconciler", func() { var hbEvents []cloudevents.Event for _, e := range pub.published { - if e.Type() == "osac.resource.heartbeat.v1" { + if e.Type() == events.EventHeartbeat { hbEvents = append(hbEvents, e) } } diff --git a/osac-metering/metering-service/internal/watch/consumer.go b/osac-metering/metering-service/internal/watch/consumer.go index c635cfb05..132213fd5 100644 --- a/osac-metering/metering-service/internal/watch/consumer.go +++ b/osac-metering/metering-service/internal/watch/consumer.go @@ -243,26 +243,22 @@ func (c *Consumer) handleTransientState( } func (c *Consumer) publishLifecycleEvents(ctx context.Context, baseCE *cloudevents.Event, mapper events.ResourceMapper, eventID string) error { - if baseCE.Type() == "osac.resource.created.v1" || baseCE.Type() == "osac.resource.deleted.v1" { + if baseCE.Type() == events.EventCreated || baseCE.Type() == events.EventDeleted { return c.publishWithRetry(ctx, baseCE) } - if mapper.ResourceType() == "cluster_order" { - decomposed, err := events.DecomposeClusterEvents(mapper.BillingDimensionsMap(), eventID, func(dims map[string]any, compEventID string) (cloudevents.Event, error) { - return c.buildComponentEvent(baseCE, compEventID, dims) - }) - if err != nil { + decomposed, err := events.BuildResourceEvents(mapper.ResourceType(), mapper.BillingDimensionsMap(), eventID, func(dims map[string]any, compEventID string) (cloudevents.Event, error) { + return c.buildComponentEvent(baseCE, compEventID, dims) + }) + if err != nil { + return err + } + for i := range decomposed { + if err := c.publishWithRetry(ctx, &decomposed[i]); err != nil { return err } - for i := range decomposed { - if err := c.publishWithRetry(ctx, &decomposed[i]); err != nil { - return err - } - } - return nil } - - return c.publishWithRetry(ctx, baseCE) + return nil } func (c *Consumer) handleScalingEvent(ctx context.Context, event *privatev1.Event, mapper events.ResourceMapper, existing *projection.ResourceState, transitionTime time.Time, version int32, currentState string, isBillable bool, dims map[string]any) error { @@ -320,7 +316,7 @@ func (c *Consumer) buildScalingEvent(eventID string, mapper events.ResourceMappe ce := cloudevents.NewEvent() ce.SetID(events.ComponentEventID(eventID, comp)) ce.SetSource("osac-metering") - ce.SetType("osac.resource.updated.v1") + ce.SetType(events.EventUpdated) ce.SetTime(transitionTime) projectID := "" diff --git a/osac-metering/metering-service/internal/watch/consumer_test.go b/osac-metering/metering-service/internal/watch/consumer_test.go index ab9a27cb7..93389775b 100644 --- a/osac-metering/metering-service/internal/watch/consumer_test.go +++ b/osac-metering/metering-service/internal/watch/consumer_test.go @@ -16,6 +16,7 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" privatev1 "github.com/osac-project/osac-metering/internal/api/osac/private/v1" + "github.com/osac-project/osac-metering/internal/events" "github.com/osac-project/osac-metering/internal/projection" "github.com/osac-project/osac-metering/internal/watch" ) @@ -260,7 +261,7 @@ var _ = Describe("Consumer", func() { pub.mu.Lock() defer pub.mu.Unlock() Expect(pub.published).To(HaveLen(1)) - Expect(pub.published[0].Type()).To(Equal("osac.resource.created.v1")) + Expect(pub.published[0].Type()).To(Equal(events.EventCreated)) }) It("stops gracefully on context cancellation", func() { @@ -493,7 +494,7 @@ var _ = Describe("Consumer", func() { now := time.Now().UTC().Truncate(time.Microsecond) store.states["vm-1"] = projection.ResourceState{ ResourceID: "vm-1", - ResourceType: "compute_instance", + ResourceType: events.ResourceTypeComputeInstance, TenantID: "tenant-1", CurrentState: "RUNNING", IsBillable: true, @@ -529,7 +530,7 @@ var _ = Describe("Consumer", func() { now := time.Now().UTC().Truncate(time.Microsecond) store.states["vm-resume"] = projection.ResourceState{ ResourceID: "vm-resume", - ResourceType: "compute_instance", + ResourceType: events.ResourceTypeComputeInstance, TenantID: "tenant-1", CurrentState: "STOPPED", IsBillable: false, @@ -561,7 +562,7 @@ var _ = Describe("Consumer", func() { pub.mu.Lock() defer pub.mu.Unlock() Expect(pub.published).To(HaveLen(1)) - Expect(pub.published[0].Type()).To(Equal("osac.resource.resumed.v1")) + Expect(pub.published[0].Type()).To(Equal(events.EventResumed)) }) It("deletes projection on OBJECT_DELETED and publishes event", func() { @@ -569,7 +570,7 @@ var _ = Describe("Consumer", func() { now := time.Now().UTC().Truncate(time.Microsecond) store.states["vm-del"] = projection.ResourceState{ ResourceID: "vm-del", - ResourceType: "compute_instance", + ResourceType: events.ResourceTypeComputeInstance, TenantID: "tenant-1", CurrentState: "RUNNING", IsBillable: true, @@ -600,7 +601,7 @@ var _ = Describe("Consumer", func() { pub.mu.Lock() defer pub.mu.Unlock() Expect(pub.published).To(HaveLen(1)) - Expect(pub.published[0].Type()).To(Equal("osac.resource.deleted.v1")) + Expect(pub.published[0].Type()).To(Equal(events.EventDeleted)) store.mu.Lock() defer store.mu.Unlock() @@ -612,7 +613,7 @@ var _ = Describe("Consumer", func() { now := time.Now().UTC().Truncate(time.Microsecond) store.states["vm-stale"] = projection.ResourceState{ ResourceID: "vm-stale", - ResourceType: "compute_instance", + ResourceType: events.ResourceTypeComputeInstance, TenantID: "tenant-1", CurrentState: "STOPPED", IsBillable: false, @@ -649,13 +650,12 @@ var _ = Describe("Consumer", func() { Expect(store.states["vm-stale"].FulfillmentVersion).To(Equal(int32(10))) }) - It("closes billing interval and resets BillableSince on dimension change while billable", func() { + It("updates projection on dimension change while billable (RUNNING->RUNNING)", func() { store := newMockStore() originalStart := time.Now().Add(-1 * time.Hour).UTC().Truncate(time.Microsecond) - instanceType := "m5.large" store.states["vm-resize"] = projection.ResourceState{ ResourceID: "vm-resize", - ResourceType: "compute_instance", + ResourceType: events.ResourceTypeComputeInstance, TenantID: "tenant-1", CurrentState: "RUNNING", IsBillable: true, @@ -670,7 +670,6 @@ var _ = Describe("Consumer", func() { ci.Status.State = privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_RUNNING ci.Metadata.Version = 2 ci.Spec = &privatev1.ComputeInstanceSpec{InstanceType: &privatev1.InstanceTypeReference{Name: newType}} - _ = instanceType event := &privatev1.Event{ Id: "evt-resize", @@ -683,20 +682,17 @@ var _ = Describe("Consumer", func() { } client.results = []mockStreamResult{{stream: stream}} - pub := &mockPublisher{published: make([]cloudevents.Event, 0, 1), cancelFunc: cancel} + // RUNNING->RUNNING is Skip; non-component dimension change + // updates projection only (no CloudEvent published for VMaaS). + pub := &mockPublisher{} consumer := newConsumerWithStore(pub, store) - err := consumer.Run(ctx) - Expect(err).ToNot(HaveOccurred()) - - pub.mu.Lock() - defer pub.mu.Unlock() - Expect(pub.published).To(HaveLen(1)) + done := make(chan error, 1) + go func() { done <- consumer.Run(ctx) }() - var data map[string]any - Expect(json.Unmarshal(pub.published[0].Data(), &data)).To(Succeed()) - Expect(data["duration_seconds"]).ToNot(BeNil()) - Expect(data["duration_seconds"]).To(BeNumerically(">", 0)) + time.Sleep(50 * time.Millisecond) + cancel() + Eventually(done, time.Second).Should(Receive(BeNil())) store.mu.Lock() defer store.mu.Unlock() @@ -711,7 +707,7 @@ var _ = Describe("Consumer", func() { billableStart := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) store.states["vm-stop-seq"] = projection.ResourceState{ ResourceID: "vm-stop-seq", - ResourceType: "compute_instance", + ResourceType: events.ResourceTypeComputeInstance, TenantID: "tenant-1", CurrentState: "RUNNING", IsBillable: true, @@ -763,7 +759,7 @@ var _ = Describe("Consumer", func() { // STOPPING is transient — no CloudEvent published for it. // Only suspended.v1 for STOPPED should be published. Expect(pub.published).To(HaveLen(1)) - Expect(pub.published[0].Type()).To(Equal("osac.resource.suspended.v1")) + Expect(pub.published[0].Type()).To(Equal(events.EventSuspended)) // suspended.v1 should have duration_seconds = 3600 (1 hour from // BillableSince to STOPPED transition time), proving billing context @@ -867,8 +863,8 @@ var _ = Describe("Consumer", func() { pub.mu.Lock() defer pub.mu.Unlock() Expect(pub.published).To(HaveLen(1)) - Expect(pub.published[0].Type()).To(Equal("osac.resource.created.v1")) - Expect(pub.published[0].Extensions()["osacresourcetype"]).To(Equal("cluster_order")) + Expect(pub.published[0].Type()).To(Equal(events.EventCreated)) + Expect(pub.published[0].Extensions()["osacresourcetype"]).To(Equal(events.ResourceTypeClusterOrder)) }) It("publishes N+1 started.v1 events for new cluster PROGRESSING", func() { @@ -895,7 +891,7 @@ var _ = Describe("Consumer", func() { defer pub.mu.Unlock() Expect(pub.published).To(HaveLen(3)) for _, e := range pub.published { - Expect(e.Type()).To(Equal("osac.resource.started.v1")) + Expect(e.Type()).To(Equal(events.EventStarted)) } }) @@ -973,7 +969,7 @@ var _ = Describe("Consumer", func() { now := time.Now().UTC().Truncate(time.Microsecond) store.states["cl-ready"] = projection.ResourceState{ ResourceID: "cl-ready", - ResourceType: "cluster_order", + ResourceType: events.ResourceTypeClusterOrder, TenantID: "tenant-1", CurrentState: "PROGRESSING", IsBillable: true, @@ -1021,7 +1017,7 @@ var _ = Describe("Consumer", func() { now := time.Now().UTC().Truncate(time.Microsecond) store.states["cl-fail"] = projection.ResourceState{ ResourceID: "cl-fail", - ResourceType: "cluster_order", + ResourceType: events.ResourceTypeClusterOrder, TenantID: "tenant-1", CurrentState: "READY", IsBillable: true, @@ -1053,7 +1049,7 @@ var _ = Describe("Consumer", func() { defer pub.mu.Unlock() Expect(pub.published).To(HaveLen(3)) for _, e := range pub.published { - Expect(e.Type()).To(Equal("osac.resource.suspended.v1")) + Expect(e.Type()).To(Equal(events.EventSuspended)) } store.mu.Lock() @@ -1067,7 +1063,7 @@ var _ = Describe("Consumer", func() { now := time.Now().Add(-1 * time.Hour).UTC().Truncate(time.Microsecond) store.states["cl-scale"] = projection.ResourceState{ ResourceID: "cl-scale", - ResourceType: "cluster_order", + ResourceType: events.ResourceTypeClusterOrder, TenantID: "tenant-1", CurrentState: "READY", IsBillable: true, @@ -1103,7 +1099,7 @@ var _ = Describe("Consumer", func() { pub.mu.Lock() defer pub.mu.Unlock() Expect(pub.published).To(HaveLen(1)) - Expect(pub.published[0].Type()).To(Equal("osac.resource.updated.v1")) + Expect(pub.published[0].Type()).To(Equal(events.EventUpdated)) var data map[string]any Expect(json.Unmarshal(pub.published[0].Data(), &data)).To(Succeed()) @@ -1118,7 +1114,7 @@ var _ = Describe("Consumer", func() { now := time.Now().UTC().Truncate(time.Microsecond) store.states["cl-del"] = projection.ResourceState{ ResourceID: "cl-del", - ResourceType: "cluster_order", + ResourceType: events.ResourceTypeClusterOrder, TenantID: "tenant-1", CurrentState: "DELETING", IsBillable: false, @@ -1149,7 +1145,7 @@ var _ = Describe("Consumer", func() { pub.mu.Lock() defer pub.mu.Unlock() Expect(pub.published).To(HaveLen(1)) - Expect(pub.published[0].Type()).To(Equal("osac.resource.deleted.v1")) + Expect(pub.published[0].Type()).To(Equal(events.EventDeleted)) store.mu.Lock() defer store.mu.Unlock() From 8980bc98d9cdadd3bf23b23c5bfdde7983ae1eb1 Mon Sep 17 00:00:00 2001 From: omer-vishlitzky Date: Thu, 6 Aug 2026 20:07:23 +0300 Subject: [PATCH 14/18] OSAC-3696: skip SIGNALED events and metadata-only updates in Watch Consumer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Watch Consumer crashed and reconnected on every OBJECT_SIGNALED event (7 per E2E run) and every metadata-only OBJECT_UPDATED without state_transition_time (3 per run). Each crash lost the bad event and all subsequent events on that stream, since the Watch protocol has no resume token. Two targeted fixes — everything else still fails fast: 1. SIGNALED: ResolveCloudEventType and ResolveTransitionTime return ErrUnsupportedEvent. The consumer skips the event, increments osac_metering_events_skipped_total{reason=unsupported_event_type}, and continues processing the same stream. 2. Metadata-only updates: when TransitionTime returns ErrDataQuality but the resource state hasn't changed (same as projection), the consumer skips silently — no state transition occurred, nothing to meter. Real data quality issues (state changed but no timestamp, missing resource_id, missing tenant_id) still crash the stream so they surface as fulfillment-service bugs. Assisted-by: Claude Code Signed-off-by: omer-vishlitzky --- .../internal/events/mapper_test.go | 12 ++ .../internal/events/transitions.go | 9 +- .../internal/events/transitions_test.go | 13 +- .../internal/watch/consumer.go | 25 +++- .../internal/watch/consumer_test.go | 121 ++++++++++++++++++ 5 files changed, 170 insertions(+), 10 deletions(-) diff --git a/osac-metering/metering-service/internal/events/mapper_test.go b/osac-metering/metering-service/internal/events/mapper_test.go index 5f4724c6e..3a9793bb9 100644 --- a/osac-metering/metering-service/internal/events/mapper_test.go +++ b/osac-metering/metering-service/internal/events/mapper_test.go @@ -241,6 +241,18 @@ var _ = Describe("MapWatchEvent", func() { privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_UNSPECIFIED, "UNSPECIFIED", "", true, false), ) + It("returns ErrUnsupportedEvent for OBJECT_SIGNALED", func() { + event := &privatev1.Event{ + Id: "evt-signaled", + Type: privatev1.EventType_EVENT_TYPE_OBJECT_SIGNALED, + Payload: &privatev1.Event_ComputeInstance{ComputeInstance: ci}, + } + + _, err := mapEvent(event, &events.StateContext{}) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, events.ErrUnsupportedEvent)).To(BeTrue()) + }) + It("returns error for unknown state (missing table entry)", func() { ci.Status.State = privatev1.ComputeInstanceState(9999) diff --git a/osac-metering/metering-service/internal/events/transitions.go b/osac-metering/metering-service/internal/events/transitions.go index 7be08ba10..bab997ac5 100644 --- a/osac-metering/metering-service/internal/events/transitions.go +++ b/osac-metering/metering-service/internal/events/transitions.go @@ -21,8 +21,9 @@ import ( ) var ( - ErrTransientState = errors.New("transient state: update projection only, no CloudEvent") - ErrSkipTransition = errors.New("no billing boundary: skip event, check for scaling") + ErrTransientState = errors.New("transient state: update projection only, no CloudEvent") + ErrSkipTransition = errors.New("no billing boundary: skip event, check for scaling") + ErrUnsupportedEvent = errors.New("unsupported event type") ) // CloudEvent type constants. @@ -99,7 +100,7 @@ func ResolveCloudEventType(table TransitionTable, eventType privatev1.EventType, if eventType == privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED { return resolveTransition(table, previousState, currentState) } - return "", fmt.Errorf("unsupported event type: %v", eventType) + return "", fmt.Errorf("%w: %v", ErrUnsupportedEvent, eventType) } // ResolveTransitionTime selects the appropriate timestamp for a given event type. @@ -112,7 +113,7 @@ func ResolveTransitionTime(eventType privatev1.EventType, creation, deletion, st ts, ok := timestamps[eventType] if !ok { - return time.Time{}, fmt.Errorf("unsupported event type for timestamp: %v", eventType) + return time.Time{}, fmt.Errorf("%w for timestamp: %v", ErrUnsupportedEvent, eventType) } if ts == nil { return time.Time{}, fmt.Errorf("%w: resource %s has no timestamp for event type %v", ErrDataQuality, resourceID, eventType) diff --git a/osac-metering/metering-service/internal/events/transitions_test.go b/osac-metering/metering-service/internal/events/transitions_test.go index 48a89d5e2..61396299d 100644 --- a/osac-metering/metering-service/internal/events/transitions_test.go +++ b/osac-metering/metering-service/internal/events/transitions_test.go @@ -121,13 +121,22 @@ var _ = Describe("ResolveTransitionTime", func() { Expect(t).To(Equal(stateTransitionTS.AsTime())) }) - It("returns error for unknown event type", func() { + It("returns ErrUnsupportedEvent for OBJECT_SIGNALED", func() { + _, err := events.ResolveTransitionTime( + privatev1.EventType_EVENT_TYPE_OBJECT_SIGNALED, + creationTS, deletionTS, stateTransitionTS, "res-1", + ) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, events.ErrUnsupportedEvent)).To(BeTrue()) + }) + + It("returns ErrUnsupportedEvent for unknown event type", func() { _, err := events.ResolveTransitionTime( privatev1.EventType(9999), creationTS, deletionTS, stateTransitionTS, "res-1", ) Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("unsupported event type for timestamp")) + Expect(errors.Is(err, events.ErrUnsupportedEvent)).To(BeTrue()) }) It("returns ErrDataQuality when creation timestamp is nil for CREATED", func() { diff --git a/osac-metering/metering-service/internal/watch/consumer.go b/osac-metering/metering-service/internal/watch/consumer.go index 132213fd5..d949dc43b 100644 --- a/osac-metering/metering-service/internal/watch/consumer.go +++ b/osac-metering/metering-service/internal/watch/consumer.go @@ -26,10 +26,16 @@ import ( "github.com/osac-project/osac-metering/internal/projection" ) -var watchReconnects = promauto.NewCounter(prometheus.CounterOpts{ - Name: "osac_metering_watch_stream_reconnects_total", - Help: "Total Watch stream reconnections", -}) +var ( + watchReconnects = promauto.NewCounter(prometheus.CounterOpts{ + Name: "osac_metering_watch_stream_reconnects_total", + Help: "Total Watch stream reconnections", + }) + eventsSkipped = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "osac_metering_events_skipped_total", + Help: "Watch events skipped due to unsupported type or data quality issues", + }, []string{"reason"}) +) const ( defaultInitialDelay = 1 * time.Second @@ -139,6 +145,17 @@ func (c *Consumer) handleEvent(ctx context.Context, event *privatev1.Event) erro transitionTime, err := mapper.TransitionTime(event.GetType()) if err != nil { + if errors.Is(err, events.ErrUnsupportedEvent) { + eventsSkipped.WithLabelValues("unsupported_event_type").Inc() + c.logger.V(1).Info("skipping unsupported event type", + "event_id", event.GetId(), "resource_id", resourceID) + return nil + } + if errors.Is(err, events.ErrDataQuality) && existing != nil && existing.CurrentState == currentState { + c.logger.V(1).Info("skipping metadata-only update with no state change", + "event_id", event.GetId(), "resource_id", resourceID, "state", currentState) + return nil + } return err } diff --git a/osac-metering/metering-service/internal/watch/consumer_test.go b/osac-metering/metering-service/internal/watch/consumer_test.go index 93389775b..89d55dd92 100644 --- a/osac-metering/metering-service/internal/watch/consumer_test.go +++ b/osac-metering/metering-service/internal/watch/consumer_test.go @@ -489,6 +489,127 @@ var _ = Describe("Consumer", func() { Expect(client.watchCallCount()).To(BeNumerically(">=", 2)) }) + It("skips OBJECT_SIGNALED without killing the stream", func() { + ciRunning := makeComputeInstance("vm-signaled", "tenant-1") + ciRunning.Status.State = privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_RUNNING + + stream := &mockWatchStream{ + responses: []*privatev1.EventsWatchResponse{ + makeResponse(&privatev1.Event{ + Id: "evt-signaled", + Type: privatev1.EventType_EVENT_TYPE_OBJECT_SIGNALED, + Payload: &privatev1.Event_ComputeInstance{ComputeInstance: ciRunning}, + }), + makeResponse(makeEvent("evt-after", privatev1.EventType_EVENT_TYPE_OBJECT_CREATED)), + }, + } + client.results = []mockStreamResult{{stream: stream}} + + pub := &mockPublisher{published: make([]cloudevents.Event, 0, 1), cancelFunc: cancel} + consumer := newConsumer(pub) + + err := consumer.Run(ctx) + Expect(err).ToNot(HaveOccurred()) + + // Single stream — no reconnect + Expect(client.watchCallCount()).To(Equal(1)) + + pub.mu.Lock() + defer pub.mu.Unlock() + Expect(pub.published).To(HaveLen(1)) + Expect(pub.published[0].Type()).To(Equal("osac.resource.created.v1")) + }) + + It("skips metadata-only update with no state_transition_time without killing the stream", func() { + store := newMockStore() + store.states["vm-meta"] = projection.ResourceState{ + ResourceID: "vm-meta", + ResourceType: "compute_instance", + TenantID: "tenant-1", + CurrentState: "STARTING", + } + + ciNoTimestamp := makeComputeInstance("vm-meta", "tenant-1") + ciNoTimestamp.Status.State = privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STARTING + ciNoTimestamp.Status.StateTransitionTime = nil + + ciRunning := makeComputeInstance("vm-meta", "tenant-1") + ciRunning.Status.State = privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_RUNNING + ciRunning.Metadata.Version = 2 + + stream := &mockWatchStream{ + responses: []*privatev1.EventsWatchResponse{ + makeResponse(&privatev1.Event{ + Id: "evt-meta-update", + Type: privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, + Payload: &privatev1.Event_ComputeInstance{ComputeInstance: ciNoTimestamp}, + }), + makeResponse(&privatev1.Event{ + Id: "evt-running", + Type: privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, + Payload: &privatev1.Event_ComputeInstance{ComputeInstance: ciRunning}, + }), + }, + } + client.results = []mockStreamResult{{stream: stream}} + + pub := &mockPublisher{published: make([]cloudevents.Event, 0, 1), cancelFunc: cancel} + consumer := newConsumerWithStore(pub, store) + + err := consumer.Run(ctx) + Expect(err).ToNot(HaveOccurred()) + + // Single stream — no reconnect + Expect(client.watchCallCount()).To(Equal(1)) + + pub.mu.Lock() + defer pub.mu.Unlock() + Expect(pub.published).To(HaveLen(1)) + Expect(pub.published[0].Type()).To(Equal("osac.resource.started.v1")) + }) + + It("fails fast on data quality error when state actually changed", func() { + store := newMockStore() + store.states["vm-dq"] = projection.ResourceState{ + ResourceID: "vm-dq", + ResourceType: "compute_instance", + TenantID: "tenant-1", + CurrentState: "RUNNING", + } + + ciStopped := makeComputeInstance("vm-dq", "tenant-1") + ciStopped.Status.State = privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STOPPED + ciStopped.Status.StateTransitionTime = nil + + goodEvent := makeEvent("evt-after", privatev1.EventType_EVENT_TYPE_OBJECT_CREATED) + + stream1 := &mockWatchStream{ + responses: []*privatev1.EventsWatchResponse{ + makeResponse(&privatev1.Event{ + Id: "evt-dq", + Type: privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, + Payload: &privatev1.Event_ComputeInstance{ComputeInstance: ciStopped}, + }), + }, + } + stream2 := &mockWatchStream{ + responses: []*privatev1.EventsWatchResponse{makeResponse(goodEvent)}, + } + client.results = []mockStreamResult{ + {stream: stream1}, + {stream: stream2}, + } + + pub := &mockPublisher{published: make([]cloudevents.Event, 0, 1), cancelFunc: cancel} + consumer := newConsumerWithStore(pub, store) + + err := consumer.Run(ctx) + Expect(err).ToNot(HaveOccurred()) + + // Stream reconnected — real data quality issue, fail fast + Expect(client.watchCallCount()).To(BeNumerically(">=", 2)) + }) + It("skips same-state same-dimensions updates", func() { store := newMockStore() now := time.Now().UTC().Truncate(time.Microsecond) From 9a784e8bb8715d25fd3a7baadf8ca0f29479e140 Mon Sep 17 00:00:00 2001 From: omer-vishlitzky Date: Thu, 6 Aug 2026 20:32:35 +0300 Subject: [PATCH 15/18] fix: address masayag review findings 1-4 on design compliance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding 1 (CRITICAL): VMaaS dimension change was silently dropped. handleScalingEvent called ChangedComponents (CaaS-specific), returned empty for VMaaS, updated projection without publishing. Fixed with BuildDimensionChangeEvents map-based dispatch: VMaaS emits single updated.v1, CaaS emits per-changed-component events. Also fixed ChangedComponents to compare HostType (not just NodeCount). Finding 2 (HIGH): billing dimension field renamed from version_name to release_image, matching the design doc and CaaS events spec gist adapter contract. Finding 3 (MEDIUM): cluster_order created.v1/deleted.v1 now have flat billing_dimensions (cluster_template, release_image only), no nested components array. Matches design spec for audit events. Finding 4 (MEDIUM): non-billable→billable transitions now consistently use resumed.v1 across both VMaaS and CaaS. Only initial ""→billable uses started.v1. Eliminates adapter special-casing between resource types. Signed-off-by: omer-vishlitzky Assisted-by: Claude Code Signed-off-by: omer-vishlitzky --- .../internal/events/cluster.go | 19 ++- .../internal/events/cluster_test.go | 50 ++++-- .../internal/events/compute_instance.go | 10 +- .../internal/events/mapper_test.go | 20 +-- .../internal/events/transitions.go | 43 +++++ .../internal/events/transitions_test.go | 67 ++++++++ .../internal/heartbeat/generator_test.go | 2 +- .../reconciliation/reconciler_test.go | 4 +- .../internal/watch/consumer.go | 66 ++++++-- .../internal/watch/consumer_test.go | 158 ++++++++++++++++-- 10 files changed, 370 insertions(+), 69 deletions(-) diff --git a/osac-metering/metering-service/internal/events/cluster.go b/osac-metering/metering-service/internal/events/cluster.go index 599411c2b..d559eddb2 100644 --- a/osac-metering/metering-service/internal/events/cluster.go +++ b/osac-metering/metering-service/internal/events/cluster.go @@ -22,6 +22,9 @@ import ( const ClusterStatePrefix = "CLUSTER_STATE_" +// DimensionReleaseImage is the billing dimension key for the cluster release image. +const DimensionReleaseImage = "release_image" + // CaaS cluster state constants. const ( ClusterStateProgressing = "PROGRESSING" @@ -187,7 +190,7 @@ func ClusterBillingDimensions(cl *privatev1.Cluster) map[string]any { dims["cluster_template"] = t.GetName() } if vn := spec.GetVersionName(); vn != "" { - dims["version_name"] = vn + dims[DimensionReleaseImage] = vn } // Use []any (not []map[string]any) so DecomposeClusterComponents' type @@ -229,7 +232,7 @@ type ComponentRecord struct { HostType string NodeCount int32 ClusterTemplate string - VersionName string + ReleaseImage string } // FlatBillingDimensions returns per-component billing dimensions for a single @@ -242,8 +245,8 @@ func (cr ComponentRecord) FlatBillingDimensions() map[string]any { "host_type": cr.HostType, "node_count": cr.NodeCount, } - if cr.VersionName != "" { - dims["version_name"] = cr.VersionName + if cr.ReleaseImage != "" { + dims[DimensionReleaseImage] = cr.ReleaseImage } return dims } @@ -253,7 +256,7 @@ func (cr ComponentRecord) FlatBillingDimensions() map[string]any { // Reconciler to fan out one cluster into per-component events. func DecomposeClusterComponents(billingDims map[string]any) []ComponentRecord { clusterTemplate, _ := billingDims["cluster_template"].(string) - versionName, _ := billingDims["version_name"].(string) + releaseImage, _ := billingDims[DimensionReleaseImage].(string) componentsRaw, ok := billingDims["components"] if !ok { @@ -286,7 +289,7 @@ func DecomposeClusterComponents(billingDims map[string]any) []ComponentRecord { HostType: hostType, NodeCount: nodeCount, ClusterTemplate: clusterTemplate, - VersionName: versionName, + ReleaseImage: releaseImage, }) } @@ -336,7 +339,7 @@ func ChangedComponents(oldDims, newDims map[string]any) []ComponentRecord { for _, r := range newRecords { newByKey[r.NodeSet] = true old, exists := oldByKey[r.NodeSet] - if !exists || old.NodeCount != r.NodeCount { + if !exists || old.NodeCount != r.NodeCount || old.HostType != r.HostType { changed = append(changed, r) } } @@ -349,7 +352,7 @@ func ChangedComponents(oldDims, newDims map[string]any) []ComponentRecord { HostType: r.HostType, NodeCount: 0, ClusterTemplate: r.ClusterTemplate, - VersionName: r.VersionName, + ReleaseImage: r.ReleaseImage, }) } } diff --git a/osac-metering/metering-service/internal/events/cluster_test.go b/osac-metering/metering-service/internal/events/cluster_test.go index d36addf66..94fe97466 100644 --- a/osac-metering/metering-service/internal/events/cluster_test.go +++ b/osac-metering/metering-service/internal/events/cluster_test.go @@ -297,10 +297,10 @@ var _ = Describe("CaaS Cluster Mapper", func() { }) Context("billing dimensions", func() { - It("includes cluster_template, version_name, and full components breakdown", func() { + It("includes cluster_template, release_image, and full components breakdown", func() { dims := events.ClusterBillingDimensions(cl) Expect(dims["cluster_template"]).To(Equal("ocp-ci-small")) - Expect(dims["version_name"]).To(Equal("quay.io/openshift-release-dev/ocp-release:4.17.0-x86_64")) + Expect(dims["release_image"]).To(Equal("quay.io/openshift-release-dev/ocp-release:4.17.0-x86_64")) components, ok := dims["components"].([]any) Expect(ok).To(BeTrue(), "components must be []any for DecomposeClusterComponents compatibility") @@ -328,10 +328,10 @@ var _ = Describe("CaaS Cluster Mapper", func() { Expect(w2["node_count"]).To(Equal(int32(2))) }) - It("omits version_name when nil", func() { + It("omits release_image when nil", func() { cl.Spec.VersionName = nil dims := events.ClusterBillingDimensions(cl) - Expect(dims).NotTo(HaveKey("version_name")) + Expect(dims).NotTo(HaveKey("release_image")) }) It("handles nil spec gracefully", func() { @@ -516,7 +516,7 @@ var _ = Describe("DecomposeClusterComponents", func() { It("decomposes 1 control plane + 2 worker sets into 3 records", func() { dims := map[string]any{ "cluster_template": "ocp-ci-small", - "version_name": "quay.io/ocp:4.17.0", + "release_image": "quay.io/ocp:4.17.0", "components": []any{ map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, map[string]any{"node_set": "cpu-workers", "component": "worker", "host_type": "cpu-only", "node_count": int32(3)}, @@ -532,7 +532,7 @@ var _ = Describe("DecomposeClusterComponents", func() { Expect(records[0].HostType).To(Equal("_control_plane")) Expect(records[0].NodeCount).To(Equal(int32(1))) Expect(records[0].ClusterTemplate).To(Equal("ocp-ci-small")) - Expect(records[0].VersionName).To(Equal("quay.io/ocp:4.17.0")) + Expect(records[0].ReleaseImage).To(Equal("quay.io/ocp:4.17.0")) Expect(records[1].NodeSet).To(Equal("cpu-workers")) Expect(records[1].Component).To(Equal("worker")) @@ -578,19 +578,19 @@ var _ = Describe("ComponentRecord", func() { HostType: "gpu-h100", NodeCount: 2, ClusterTemplate: "ocp-ci-small", - VersionName: "quay.io/ocp:4.17.0", + ReleaseImage: "quay.io/ocp:4.17.0", } flat := cr.FlatBillingDimensions() Expect(flat["cluster_template"]).To(Equal("ocp-ci-small")) - Expect(flat["version_name"]).To(Equal("quay.io/ocp:4.17.0")) + Expect(flat["release_image"]).To(Equal("quay.io/ocp:4.17.0")) Expect(flat["node_set"]).To(Equal("gpu-workers")) Expect(flat["component"]).To(Equal("worker")) Expect(flat["host_type"]).To(Equal("gpu-h100")) Expect(flat["node_count"]).To(Equal(int32(2))) }) - It("omits version_name when empty", func() { + It("omits release_image when empty", func() { cr := events.ComponentRecord{ NodeSet: "_control_plane", Component: "control_plane", @@ -600,7 +600,7 @@ var _ = Describe("ComponentRecord", func() { } flat := cr.FlatBillingDimensions() - Expect(flat).NotTo(HaveKey("version_name")) + Expect(flat).NotTo(HaveKey("release_image")) }) }) @@ -733,6 +733,28 @@ var _ = Describe("ChangedComponents", func() { Expect(nodeSetToHostType["pool-b"]).To(Equal("cpu-only")) }) + It("detects host_type change within a node set", func() { + oldDims := map[string]any{ + "cluster_template": "tmpl", + "components": []any{ + map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, + }, + } + newDims := map[string]any{ + "cluster_template": "tmpl", + "components": []any{ + map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-a100", "node_count": int32(2)}, + }, + } + + changed := events.ChangedComponents(oldDims, newDims) + Expect(changed).To(HaveLen(1)) + Expect(changed[0].HostType).To(Equal("gpu-a100")) + Expect(changed[0].NodeCount).To(Equal(int32(2))) + }) + It("handles int32 vs float64 from JSONB round-trip", func() { oldDims := map[string]any{ "cluster_template": "tmpl", @@ -755,7 +777,7 @@ var _ = Describe("DimensionsEqual with nested CaaS components", func() { It("matches identical billing dimensions with components array", func() { a := map[string]any{ "cluster_template": "ocp-ci-small", - "version_name": "quay.io/ocp:4.17.0", + "release_image": "quay.io/ocp:4.17.0", "components": []any{ map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, @@ -763,7 +785,7 @@ var _ = Describe("DimensionsEqual with nested CaaS components", func() { } b := map[string]any{ "cluster_template": "ocp-ci-small", - "version_name": "quay.io/ocp:4.17.0", + "release_image": "quay.io/ocp:4.17.0", "components": []any{ map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, @@ -808,7 +830,7 @@ var _ = Describe("DimensionsEqual with nested CaaS components", func() { It("handles JSONB round-trip: int32 stored, float64 on read", func() { stored := map[string]any{ "cluster_template": "tmpl", - "version_name": "quay.io/ocp:4.17.0", + "release_image": "quay.io/ocp:4.17.0", "components": []any{ map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, @@ -853,7 +875,7 @@ var _ = Describe("DimensionsEqual with nested CaaS components", func() { It("round-trip preserves equality for multi-component clusters", func() { original := map[string]any{ "cluster_template": "ocp-ci-small", - "version_name": "quay.io/ocp:4.17.0", + "release_image": "quay.io/ocp:4.17.0", "components": []any{ map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, map[string]any{"node_set": "cpu-workers", "component": "worker", "host_type": "cpu-only", "node_count": int32(3)}, diff --git a/osac-metering/metering-service/internal/events/compute_instance.go b/osac-metering/metering-service/internal/events/compute_instance.go index f45656de6..12a736d6c 100644 --- a/osac-metering/metering-service/internal/events/compute_instance.go +++ b/osac-metering/metering-service/internal/events/compute_instance.go @@ -69,7 +69,7 @@ var computeInstanceTransitions = TransitionTable{ {ComputeInstanceStatePaused, ComputeInstanceStateUnspecified}: {Skip: true}, // --- From FAILED --- - {ComputeInstanceStateFailed, ComputeInstanceStateRunning}: {EventType: EventStarted}, + {ComputeInstanceStateFailed, ComputeInstanceStateRunning}: {EventType: EventResumed}, {ComputeInstanceStateFailed, ComputeInstanceStateStopped}: {Skip: true}, {ComputeInstanceStateFailed, ComputeInstanceStatePaused}: {Skip: true}, {ComputeInstanceStateFailed, ComputeInstanceStateFailed}: {Skip: true}, @@ -79,7 +79,7 @@ var computeInstanceTransitions = TransitionTable{ {ComputeInstanceStateFailed, ComputeInstanceStateUnspecified}: {Skip: true}, // --- From STOPPING --- - {ComputeInstanceStateStopping, ComputeInstanceStateRunning}: {EventType: EventStarted}, + {ComputeInstanceStateStopping, ComputeInstanceStateRunning}: {EventType: EventResumed}, {ComputeInstanceStateStopping, ComputeInstanceStateStopped}: {Skip: true}, {ComputeInstanceStateStopping, ComputeInstanceStatePaused}: {Skip: true}, {ComputeInstanceStateStopping, ComputeInstanceStateFailed}: {Skip: true}, @@ -89,7 +89,7 @@ var computeInstanceTransitions = TransitionTable{ {ComputeInstanceStateStopping, ComputeInstanceStateUnspecified}: {Skip: true}, // --- From STARTING --- - {ComputeInstanceStateStarting, ComputeInstanceStateRunning}: {EventType: EventStarted}, + {ComputeInstanceStateStarting, ComputeInstanceStateRunning}: {EventType: EventResumed}, {ComputeInstanceStateStarting, ComputeInstanceStateStopped}: {Skip: true}, {ComputeInstanceStateStarting, ComputeInstanceStatePaused}: {Skip: true}, {ComputeInstanceStateStarting, ComputeInstanceStateFailed}: {Skip: true}, @@ -99,7 +99,7 @@ var computeInstanceTransitions = TransitionTable{ {ComputeInstanceStateStarting, ComputeInstanceStateUnspecified}: {Skip: true}, // --- From DELETING --- - {ComputeInstanceStateDeleting, ComputeInstanceStateRunning}: {EventType: EventStarted}, + {ComputeInstanceStateDeleting, ComputeInstanceStateRunning}: {EventType: EventResumed}, {ComputeInstanceStateDeleting, ComputeInstanceStateStopped}: {Skip: true}, {ComputeInstanceStateDeleting, ComputeInstanceStatePaused}: {Skip: true}, {ComputeInstanceStateDeleting, ComputeInstanceStateFailed}: {Skip: true}, @@ -109,7 +109,7 @@ var computeInstanceTransitions = TransitionTable{ {ComputeInstanceStateDeleting, ComputeInstanceStateUnspecified}: {Skip: true}, // --- From UNSPECIFIED --- - {ComputeInstanceStateUnspecified, ComputeInstanceStateRunning}: {EventType: EventStarted}, + {ComputeInstanceStateUnspecified, ComputeInstanceStateRunning}: {EventType: EventResumed}, {ComputeInstanceStateUnspecified, ComputeInstanceStateStopped}: {Skip: true}, {ComputeInstanceStateUnspecified, ComputeInstanceStatePaused}: {Skip: true}, {ComputeInstanceStateUnspecified, ComputeInstanceStateFailed}: {Skip: true}, diff --git a/osac-metering/metering-service/internal/events/mapper_test.go b/osac-metering/metering-service/internal/events/mapper_test.go index 3a9793bb9..c59bab24f 100644 --- a/osac-metering/metering-service/internal/events/mapper_test.go +++ b/osac-metering/metering-service/internal/events/mapper_test.go @@ -151,8 +151,8 @@ var _ = Describe("MapWatchEvent", func() { privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_UNSPECIFIED, "PAUSED", "", true, false), // --- From FAILED --- - Entry("FAILED -> RUNNING -> started.v1", - privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_RUNNING, "FAILED", events.EventStarted, false, false), + Entry("FAILED -> RUNNING -> resumed.v1", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_RUNNING, "FAILED", events.EventResumed, false, false), Entry("FAILED -> STOPPED -> skip", privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STOPPED, "FAILED", "", true, false), Entry("FAILED -> PAUSED -> skip", @@ -169,8 +169,8 @@ var _ = Describe("MapWatchEvent", func() { privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_UNSPECIFIED, "FAILED", "", true, false), // --- From STOPPING --- - Entry("STOPPING -> RUNNING -> started.v1", - privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_RUNNING, "STOPPING", events.EventStarted, false, false), + Entry("STOPPING -> RUNNING -> resumed.v1", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_RUNNING, "STOPPING", events.EventResumed, false, false), Entry("STOPPING -> STOPPED -> skip", privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STOPPED, "STOPPING", "", true, false), Entry("STOPPING -> PAUSED -> skip", @@ -187,8 +187,8 @@ var _ = Describe("MapWatchEvent", func() { privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_UNSPECIFIED, "STOPPING", "", true, false), // --- From STARTING --- - Entry("STARTING -> RUNNING -> started.v1", - privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_RUNNING, "STARTING", events.EventStarted, false, false), + Entry("STARTING -> RUNNING -> resumed.v1", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_RUNNING, "STARTING", events.EventResumed, false, false), Entry("STARTING -> STOPPED -> skip", privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STOPPED, "STARTING", "", true, false), Entry("STARTING -> PAUSED -> skip", @@ -205,8 +205,8 @@ var _ = Describe("MapWatchEvent", func() { privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_UNSPECIFIED, "STARTING", "", true, false), // --- From DELETING --- - Entry("DELETING -> RUNNING -> started.v1", - privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_RUNNING, "DELETING", events.EventStarted, false, false), + Entry("DELETING -> RUNNING -> resumed.v1", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_RUNNING, "DELETING", events.EventResumed, false, false), Entry("DELETING -> STOPPED -> skip", privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STOPPED, "DELETING", "", true, false), Entry("DELETING -> PAUSED -> skip", @@ -223,8 +223,8 @@ var _ = Describe("MapWatchEvent", func() { privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_UNSPECIFIED, "DELETING", "", true, false), // --- From UNSPECIFIED --- - Entry("UNSPECIFIED -> RUNNING -> started.v1", - privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_RUNNING, "UNSPECIFIED", events.EventStarted, false, false), + Entry("UNSPECIFIED -> RUNNING -> resumed.v1", + privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_RUNNING, "UNSPECIFIED", events.EventResumed, false, false), Entry("UNSPECIFIED -> STOPPED -> skip", privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_STOPPED, "UNSPECIFIED", "", true, false), Entry("UNSPECIFIED -> PAUSED -> skip", diff --git a/osac-metering/metering-service/internal/events/transitions.go b/osac-metering/metering-service/internal/events/transitions.go index bab997ac5..99e9816d3 100644 --- a/osac-metering/metering-service/internal/events/transitions.go +++ b/osac-metering/metering-service/internal/events/transitions.go @@ -149,3 +149,46 @@ func BuildResourceEvents(resourceType string, dims map[string]any, baseID string } return decomposer(dims, baseID, buildFn) } + +// DimensionChangeHandler builds CloudEvents for billing dimension changes. +type DimensionChangeHandler func(oldDims, newDims map[string]any, baseID string, buildFn EventBuilder) ([]cloudevents.Event, error) + +var dimensionChangeHandlers = map[string]DimensionChangeHandler{ + ResourceTypeComputeInstance: singleDimensionChange, + ResourceTypeClusterOrder: componentDimensionChange, +} + +// BuildDimensionChangeEvents dispatches dimension change event building to +// the correct handler for the given resource type. VMaaS emits a single +// updated.v1 with the new dimensions; CaaS emits per-changed-component events. +func BuildDimensionChangeEvents(resourceType string, oldDims, newDims map[string]any, baseID string, buildFn EventBuilder) ([]cloudevents.Event, error) { + handler, ok := dimensionChangeHandlers[resourceType] + if !ok { + return nil, fmt.Errorf("unknown resource type for dimension change: %s", resourceType) + } + return handler(oldDims, newDims, baseID, buildFn) +} + +func singleDimensionChange(_, newDims map[string]any, baseID string, buildFn EventBuilder) ([]cloudevents.Event, error) { + ce, err := buildFn(newDims, baseID) + if err != nil { + return nil, err + } + return []cloudevents.Event{ce}, nil +} + +func componentDimensionChange(oldDims, newDims map[string]any, baseID string, buildFn EventBuilder) ([]cloudevents.Event, error) { + changed := ChangedComponents(oldDims, newDims) + if len(changed) == 0 { + return nil, nil + } + result := make([]cloudevents.Event, 0, len(changed)) + for _, comp := range changed { + ce, err := buildFn(comp.FlatBillingDimensions(), ComponentEventID(baseID, comp)) + if err != nil { + return nil, err + } + result = append(result, ce) + } + return result, nil +} diff --git a/osac-metering/metering-service/internal/events/transitions_test.go b/osac-metering/metering-service/internal/events/transitions_test.go index 61396299d..12e6388f5 100644 --- a/osac-metering/metering-service/internal/events/transitions_test.go +++ b/osac-metering/metering-service/internal/events/transitions_test.go @@ -222,6 +222,73 @@ var _ = Describe("BuildResourceEvents", func() { }) }) +var _ = Describe("BuildDimensionChangeEvents", func() { + simpleBuildFn := func(dims map[string]any, eventID string) (cloudevents.Event, error) { + ce := cloudevents.NewEvent() + ce.SetID(eventID) + if err := ce.SetData(cloudevents.ApplicationJSON, dims); err != nil { + return cloudevents.Event{}, fmt.Errorf("setting data: %w", err) + } + return ce, nil + } + + It("returns a single event for compute_instance (VMaaS)", func() { + oldDims := map[string]any{"instance_type": "m5.large"} + newDims := map[string]any{"instance_type": "m5.xlarge"} + result, err := events.BuildDimensionChangeEvents( + events.ResourceTypeComputeInstance, oldDims, newDims, "evt-ci-1", simpleBuildFn, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID()).To(Equal("evt-ci-1")) + }) + + It("returns per-changed-component events for cluster_order (CaaS)", func() { + oldDims := map[string]any{ + "cluster_template": "tmpl", + "components": []any{ + map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, + }, + } + newDims := map[string]any{ + "cluster_template": "tmpl", + "components": []any{ + map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": int32(4)}, + }, + } + result, err := events.BuildDimensionChangeEvents( + events.ResourceTypeClusterOrder, oldDims, newDims, "evt-cl-1", simpleBuildFn, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID()).To(Equal("evt-cl-1/gpu-workers")) + }) + + It("returns nil for cluster_order with no changes", func() { + dims := map[string]any{ + "cluster_template": "tmpl", + "components": []any{ + map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + }, + } + result, err := events.BuildDimensionChangeEvents( + events.ResourceTypeClusterOrder, dims, dims, "evt-cl-1", simpleBuildFn, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(BeNil()) + }) + + It("returns error for unknown resource type", func() { + _, err := events.BuildDimensionChangeEvents( + "unknown_resource", map[string]any{}, map[string]any{}, "evt-1", simpleBuildFn, + ) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("unknown resource type for dimension change")) + }) +}) + var _ = Describe("resolveTransition (indirect via ResolveCloudEventType)", func() { It("returns correct event type for an exact table match", func() { table := events.TransitionTable{ diff --git a/osac-metering/metering-service/internal/heartbeat/generator_test.go b/osac-metering/metering-service/internal/heartbeat/generator_test.go index c610927f9..58992f520 100644 --- a/osac-metering/metering-service/internal/heartbeat/generator_test.go +++ b/osac-metering/metering-service/internal/heartbeat/generator_test.go @@ -270,7 +270,7 @@ var _ = Describe("Generator", func() { BillableSince: &now, BillingDimensions: map[string]any{ "cluster_template": "ocp-ci-small", - "version_name": "4.17.0", + "release_image": "4.17.0", "components": []any{ map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": float64(1)}, map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": float64(2)}, diff --git a/osac-metering/metering-service/internal/reconciliation/reconciler_test.go b/osac-metering/metering-service/internal/reconciliation/reconciler_test.go index b5c7944c1..106c02a75 100644 --- a/osac-metering/metering-service/internal/reconciliation/reconciler_test.go +++ b/osac-metering/metering-service/internal/reconciliation/reconciler_test.go @@ -732,7 +732,7 @@ var _ = Describe("Reconciler", func() { FulfillmentVersion: 1, BillingDimensions: map[string]any{ "cluster_template": "ocp-ci-small", - "version_name": "4.17.0", + "release_image": "4.17.0", "components": []any{ map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": float64(1)}, map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": float64(2)}, @@ -883,7 +883,7 @@ var _ = Describe("Reconciler", func() { FulfillmentVersion: 1, BillingDimensions: map[string]any{ "cluster_template": "ocp-ci-small", - "version_name": "4.17.0", + "release_image": "4.17.0", "components": []any{ map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": float64(1)}, map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": float64(2)}, diff --git a/osac-metering/metering-service/internal/watch/consumer.go b/osac-metering/metering-service/internal/watch/consumer.go index d949dc43b..1de7f7d79 100644 --- a/osac-metering/metering-service/internal/watch/consumer.go +++ b/osac-metering/metering-service/internal/watch/consumer.go @@ -259,8 +259,14 @@ func (c *Consumer) handleTransientState( return nil } +// DimComponents is the billing dimensions key for the nested components array. +const DimComponents = "components" + func (c *Consumer) publishLifecycleEvents(ctx context.Context, baseCE *cloudevents.Event, mapper events.ResourceMapper, eventID string) error { if baseCE.Type() == events.EventCreated || baseCE.Type() == events.EventDeleted { + if mapper.ResourceType() == events.ResourceTypeClusterOrder { + c.stripComponentsFromAuditEvent(baseCE) + } return c.publishWithRetry(ctx, baseCE) } @@ -280,32 +286,56 @@ func (c *Consumer) publishLifecycleEvents(ctx context.Context, baseCE *cloudeven func (c *Consumer) handleScalingEvent(ctx context.Context, event *privatev1.Event, mapper events.ResourceMapper, existing *projection.ResourceState, transitionTime time.Time, version int32, currentState string, isBillable bool, dims map[string]any) error { resourceID := mapper.ResourceID() - changed := events.ChangedComponents(existing.BillingDimensions, dims) projState := c.buildProjectionState(mapper, existing, transitionTime, version, currentState, isBillable, dims) - - if len(changed) == 0 { - c.logger.V(1).Info("non-component dimension change, projection updated", - "resource_id", resourceID) - return c.publishAndUpsert(ctx, func() error { return nil }, projState, resourceID) - } - stateCtx := c.buildStateContext(existing, isBillable, transitionTime, dims) + return c.publishAndUpsert(ctx, func() error { - for _, comp := range changed { - ce, ceErr := c.buildScalingEvent(event.GetId(), mapper, comp, stateCtx, transitionTime) - if ceErr != nil { - return ceErr - } - if err := c.publishWithRetry(ctx, &ce); err != nil { + scalingEvents, err := events.BuildDimensionChangeEvents( + mapper.ResourceType(), existing.BillingDimensions, dims, event.GetId(), + func(d map[string]any, eventID string) (cloudevents.Event, error) { + return c.buildScalingEvent(eventID, mapper, d, stateCtx, transitionTime) + }) + if err != nil { + return err + } + if len(scalingEvents) == 0 { + c.logger.V(1).Info("non-component dimension change, projection updated", + "resource_id", resourceID) + return nil + } + for i := range scalingEvents { + if err := c.publishWithRetry(ctx, &scalingEvents[i]); err != nil { return err } } c.logger.Info("published scaling events", - "resource_id", resourceID, "changed_components", len(changed)) + "resource_id", resourceID, "changed_components", len(scalingEvents)) return nil }, projState, resourceID) } +// stripComponentsFromAuditEvent removes the nested "components" array from +// cluster_order created.v1/deleted.v1 events so the audit payload has flat +// billing_dimensions (just cluster_template, release_image). +func (c *Consumer) stripComponentsFromAuditEvent(ce *cloudevents.Event) { + var data map[string]any + if err := ce.DataAs(&data); err != nil { + return + } + bd, ok := data["billing_dimensions"].(map[string]any) + if !ok { + return + } + flatDims := make(map[string]any, len(bd)) + for k, v := range bd { + if k != DimComponents { + flatDims[k] = v + } + } + data["billing_dimensions"] = flatDims + _ = ce.SetData(cloudevents.ApplicationJSON, data) +} + func (c *Consumer) buildComponentEvent(baseCE *cloudevents.Event, eventID string, dims map[string]any) (cloudevents.Event, error) { ce := cloudevents.NewEvent() ce.SetID(eventID) @@ -329,9 +359,9 @@ func (c *Consumer) buildComponentEvent(baseCE *cloudevents.Event, eventID string return ce, nil } -func (c *Consumer) buildScalingEvent(eventID string, mapper events.ResourceMapper, comp events.ComponentRecord, stateCtx *events.StateContext, transitionTime time.Time) (cloudevents.Event, error) { +func (c *Consumer) buildScalingEvent(eventID string, mapper events.ResourceMapper, dims map[string]any, stateCtx *events.StateContext, transitionTime time.Time) (cloudevents.Event, error) { ce := cloudevents.NewEvent() - ce.SetID(events.ComponentEventID(eventID, comp)) + ce.SetID(eventID) ce.SetSource("osac-metering") ce.SetType(events.EventUpdated) ce.SetTime(transitionTime) @@ -358,7 +388,7 @@ func (c *Consumer) buildScalingEvent(eventID string, mapper events.ResourceMappe "current_state": mapper.CurrentState(), "transition_time": transitionTime.Format(time.RFC3339Nano), "duration_seconds": stateCtx.DurationSeconds, - "billing_dimensions": comp.FlatBillingDimensions(), + "billing_dimensions": dims, "schema_version": "v1", } if err := ce.SetData(cloudevents.ApplicationJSON, data); err != nil { diff --git a/osac-metering/metering-service/internal/watch/consumer_test.go b/osac-metering/metering-service/internal/watch/consumer_test.go index 89d55dd92..c2ba8dca0 100644 --- a/osac-metering/metering-service/internal/watch/consumer_test.go +++ b/osac-metering/metering-service/internal/watch/consumer_test.go @@ -565,7 +565,7 @@ var _ = Describe("Consumer", func() { pub.mu.Lock() defer pub.mu.Unlock() Expect(pub.published).To(HaveLen(1)) - Expect(pub.published[0].Type()).To(Equal("osac.resource.started.v1")) + Expect(pub.published[0].Type()).To(Equal("osac.resource.resumed.v1")) }) It("fails fast on data quality error when state actually changed", func() { @@ -771,7 +771,7 @@ var _ = Describe("Consumer", func() { Expect(store.states["vm-stale"].FulfillmentVersion).To(Equal(int32(10))) }) - It("updates projection on dimension change while billable (RUNNING->RUNNING)", func() { + It("publishes updated.v1 and updates projection on dimension change while billable (RUNNING->RUNNING)", func() { store := newMockStore() originalStart := time.Now().Add(-1 * time.Hour).UTC().Truncate(time.Microsecond) store.states["vm-resize"] = projection.ResourceState{ @@ -803,17 +803,23 @@ var _ = Describe("Consumer", func() { } client.results = []mockStreamResult{{stream: stream}} - // RUNNING->RUNNING is Skip; non-component dimension change - // updates projection only (no CloudEvent published for VMaaS). - pub := &mockPublisher{} + // RUNNING->RUNNING is Skip; dimension change now publishes + // updated.v1 for VMaaS via BuildDimensionChangeEvents. + pub := &mockPublisher{published: make([]cloudevents.Event, 0, 1), cancelFunc: cancel} consumer := newConsumerWithStore(pub, store) - done := make(chan error, 1) - go func() { done <- consumer.Run(ctx) }() + err := consumer.Run(ctx) + Expect(err).ToNot(HaveOccurred()) - time.Sleep(50 * time.Millisecond) - cancel() - Eventually(done, time.Second).Should(Receive(BeNil())) + pub.mu.Lock() + defer pub.mu.Unlock() + Expect(pub.published).To(HaveLen(1)) + Expect(pub.published[0].Type()).To(Equal(events.EventUpdated)) + + var data map[string]any + Expect(json.Unmarshal(pub.published[0].Data(), &data)).To(Succeed()) + bd := data["billing_dimensions"].(map[string]any) + Expect(bd["instance_type"]).To(Equal("m5.xlarge")) store.mu.Lock() defer store.mu.Unlock() @@ -823,6 +829,60 @@ var _ = Describe("Consumer", func() { Expect(updated.BillableSince.After(originalStart)).To(BeTrue()) }) + It("publishes updated.v1 when VMaaS billing dimensions change while RUNNING", func() { + store := newMockStore() + originalStart := time.Now().Add(-1 * time.Hour).UTC().Truncate(time.Microsecond) + store.states["vm-dim-change"] = projection.ResourceState{ + ResourceID: "vm-dim-change", + ResourceType: events.ResourceTypeComputeInstance, + TenantID: "tenant-1", + CurrentState: "RUNNING", + IsBillable: true, + BillableSince: &originalStart, + FulfillmentVersion: 1, + BillingDimensions: map[string]any{"instance_type": "m5.large"}, + TransitionTime: originalStart, + } + + ci := makeComputeInstance("vm-dim-change", "tenant-1") + ci.Status.State = privatev1.ComputeInstanceState_COMPUTE_INSTANCE_STATE_RUNNING + ci.Metadata.Version = 2 + ci.Spec = &privatev1.ComputeInstanceSpec{InstanceType: &privatev1.InstanceTypeReference{Name: "m5.xlarge"}} + + event := &privatev1.Event{ + Id: "evt-dim-change", + Type: privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, + Payload: &privatev1.Event_ComputeInstance{ComputeInstance: ci}, + } + + stream := &mockWatchStream{ + responses: []*privatev1.EventsWatchResponse{makeResponse(event)}, + } + client.results = []mockStreamResult{{stream: stream}} + + pub := &mockPublisher{published: make([]cloudevents.Event, 0, 1), cancelFunc: cancel} + consumer := newConsumerWithStore(pub, store) + + err := consumer.Run(ctx) + Expect(err).ToNot(HaveOccurred()) + + pub.mu.Lock() + defer pub.mu.Unlock() + Expect(pub.published).To(HaveLen(1)) + Expect(pub.published[0].Type()).To(Equal(events.EventUpdated)) + + var data map[string]any + Expect(json.Unmarshal(pub.published[0].Data(), &data)).To(Succeed()) + bd := data["billing_dimensions"].(map[string]any) + Expect(bd["instance_type"]).To(Equal("m5.xlarge")) + Expect(data["duration_seconds"]).ToNot(BeNil()) + + store.mu.Lock() + defer store.mu.Unlock() + updated := store.states["vm-dim-change"] + Expect(updated.BillingDimensions["instance_type"]).To(Equal("m5.xlarge")) + }) + It("preserves billing context through RUNNING→STOPPING→STOPPED sequence", func() { store := newMockStore() billableStart := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) @@ -953,7 +1013,7 @@ var _ = Describe("Consumer", func() { clusterBillingDims := func() map[string]any { return map[string]any{ "cluster_template": "ocp-ci-small", - "version_name": "4.17.0", + "release_image": "4.17.0", "components": []any{ map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, map[string]any{"node_set": "cpu-workers", "component": "worker", "host_type": "cpu-only", "node_count": int32(3)}, @@ -988,6 +1048,82 @@ var _ = Describe("Consumer", func() { Expect(pub.published[0].Extensions()["osacresourcetype"]).To(Equal(events.ResourceTypeClusterOrder)) }) + It("cluster created.v1 has flat billing_dimensions without components", func() { + cl := makeCluster("cl-flat", "tenant-1", privatev1.ClusterState_CLUSTER_STATE_PROGRESSING, defaultNodeSets()) + event := &privatev1.Event{ + Id: "evt-flat-create", + Type: privatev1.EventType_EVENT_TYPE_OBJECT_CREATED, + Payload: &privatev1.Event_Cluster{Cluster: cl}, + } + + stream := &mockWatchStream{ + responses: []*privatev1.EventsWatchResponse{makeResponse(event)}, + } + client.results = []mockStreamResult{{stream: stream}} + + pub := &mockPublisher{published: make([]cloudevents.Event, 0, 1), cancelFunc: cancel} + consumer := newConsumer(pub) + + err := consumer.Run(ctx) + Expect(err).ToNot(HaveOccurred()) + + pub.mu.Lock() + defer pub.mu.Unlock() + Expect(pub.published).To(HaveLen(1)) + Expect(pub.published[0].Type()).To(Equal(events.EventCreated)) + + var data map[string]any + Expect(json.Unmarshal(pub.published[0].Data(), &data)).To(Succeed()) + bd := data["billing_dimensions"].(map[string]any) + Expect(bd).To(HaveKey("cluster_template")) + Expect(bd).NotTo(HaveKey("components")) + }) + + It("cluster deleted.v1 has flat billing_dimensions without components", func() { + store := newMockStore() + now := time.Now().UTC().Truncate(time.Microsecond) + store.states["cl-flat-del"] = projection.ResourceState{ + ResourceID: "cl-flat-del", + ResourceType: events.ResourceTypeClusterOrder, + TenantID: "tenant-1", + CurrentState: "DELETING", + IsBillable: false, + FulfillmentVersion: 1, + BillingDimensions: clusterBillingDims(), + TransitionTime: now, + } + + cl := makeCluster("cl-flat-del", "tenant-1", privatev1.ClusterState_CLUSTER_STATE_DELETING, defaultNodeSets()) + cl.Metadata.DeletionTimestamp = timestamppb.Now() + event := &privatev1.Event{ + Id: "evt-flat-del", + Type: privatev1.EventType_EVENT_TYPE_OBJECT_DELETED, + Payload: &privatev1.Event_Cluster{Cluster: cl}, + } + + stream := &mockWatchStream{ + responses: []*privatev1.EventsWatchResponse{makeResponse(event)}, + } + client.results = []mockStreamResult{{stream: stream}} + + pub := &mockPublisher{published: make([]cloudevents.Event, 0, 1), cancelFunc: cancel} + consumer := newConsumerWithStore(pub, store) + + err := consumer.Run(ctx) + Expect(err).ToNot(HaveOccurred()) + + pub.mu.Lock() + defer pub.mu.Unlock() + Expect(pub.published).To(HaveLen(1)) + Expect(pub.published[0].Type()).To(Equal(events.EventDeleted)) + + var data map[string]any + Expect(json.Unmarshal(pub.published[0].Data(), &data)).To(Succeed()) + bd := data["billing_dimensions"].(map[string]any) + Expect(bd).To(HaveKey("cluster_template")) + Expect(bd).NotTo(HaveKey("components")) + }) + It("publishes N+1 started.v1 events for new cluster PROGRESSING", func() { cl := makeCluster("cl-start", "tenant-1", privatev1.ClusterState_CLUSTER_STATE_PROGRESSING, defaultNodeSets()) event := &privatev1.Event{ From 14a82a35b9f313789d7bfd6fdb7e4a603c563df5 Mon Sep 17 00:00:00 2001 From: omer-vishlitzky Date: Thu, 6 Aug 2026 20:54:22 +0300 Subject: [PATCH 16/18] refactor: pass billing dims to MapWatchEvent, eliminate post-hoc strip MapWatchEvent now takes billingDims as a parameter instead of calling mapper.BillingDimensionsMap() internally. Callers pass the right dims for their context: - Audit events (created/deleted): topLevelDims() strips components BEFORE building the CloudEvent (was: build with nested, then deserialize-strip-reserialize after) - Lifecycle events: dims passed through, replaced per-component during decomposition anyway Removes stripComponentsFromAuditEvent (serialize-deserialize round-trip). Signed-off-by: omer-vishlitzky Assisted-by: Claude Code Signed-off-by: omer-vishlitzky --- .../internal/events/cluster.go | 2 + .../internal/events/cluster_test.go | 31 ++++ .../internal/events/mapper.go | 8 +- .../internal/events/mapper_test.go | 2 +- .../internal/events/transitions.go | 43 ----- .../internal/events/transitions_test.go | 67 ------- .../internal/watch/consumer.go | 96 +++++----- .../internal/watch/consumer_test.go | 164 ++++++++++++++++++ 8 files changed, 258 insertions(+), 155 deletions(-) diff --git a/osac-metering/metering-service/internal/events/cluster.go b/osac-metering/metering-service/internal/events/cluster.go index d559eddb2..fc5c6c6a6 100644 --- a/osac-metering/metering-service/internal/events/cluster.go +++ b/osac-metering/metering-service/internal/events/cluster.go @@ -233,6 +233,7 @@ type ComponentRecord struct { NodeCount int32 ClusterTemplate string ReleaseImage string + IsNew bool } // FlatBillingDimensions returns per-component billing dimensions for a single @@ -340,6 +341,7 @@ func ChangedComponents(oldDims, newDims map[string]any) []ComponentRecord { newByKey[r.NodeSet] = true old, exists := oldByKey[r.NodeSet] if !exists || old.NodeCount != r.NodeCount || old.HostType != r.HostType { + r.IsNew = !exists changed = append(changed, r) } } diff --git a/osac-metering/metering-service/internal/events/cluster_test.go b/osac-metering/metering-service/internal/events/cluster_test.go index 94fe97466..f628007a1 100644 --- a/osac-metering/metering-service/internal/events/cluster_test.go +++ b/osac-metering/metering-service/internal/events/cluster_test.go @@ -733,6 +733,37 @@ var _ = Describe("ChangedComponents", func() { Expect(nodeSetToHostType["pool-b"]).To(Equal("cpu-only")) }) + It("sets IsNew=true for newly-added components and IsNew=false for modified", func() { + oldDims := map[string]any{ + "cluster_template": "tmpl", + "components": []any{ + map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, + }, + } + newDims := map[string]any{ + "cluster_template": "tmpl", + "components": []any{ + map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": int32(4)}, + map[string]any{"node_set": "tpu-workers", "component": "worker", "host_type": "tpu-v5", "node_count": int32(2)}, + }, + } + + changed := events.ChangedComponents(oldDims, newDims) + Expect(changed).To(HaveLen(2)) + + byNodeSet := map[string]events.ComponentRecord{} + for _, c := range changed { + byNodeSet[c.NodeSet] = c + } + + Expect(byNodeSet["gpu-workers"].IsNew).To(BeFalse(), + "modified component should have IsNew=false") + Expect(byNodeSet["tpu-workers"].IsNew).To(BeTrue(), + "newly-added component should have IsNew=true") + }) + It("detects host_type change within a node set", func() { oldDims := map[string]any{ "cluster_template": "tmpl", diff --git a/osac-metering/metering-service/internal/events/mapper.go b/osac-metering/metering-service/internal/events/mapper.go index dcd188b75..226b18ecd 100644 --- a/osac-metering/metering-service/internal/events/mapper.go +++ b/osac-metering/metering-service/internal/events/mapper.go @@ -40,8 +40,10 @@ type StateContext struct { } // MapWatchEvent converts a fulfillment-service Watch Event into a CloudEvents 1.0 -// event using the appropriate ResourceMapper for the payload type. -func MapWatchEvent(event *privatev1.Event, mapper ResourceMapper, stateCtx *StateContext) (*cloudevents.Event, error) { +// event. billingDims is the billing dimensions to embed in the event payload — +// callers pass per-component flat dims (from decomposition) or top-level-only +// dims (for audit events), never the nested stored form directly. +func MapWatchEvent(event *privatev1.Event, mapper ResourceMapper, stateCtx *StateContext, billingDims map[string]any) (*cloudevents.Event, error) { previousState := stateCtx.PreviousState ceType, err := mapper.CloudEventType(event.GetType(), previousState) @@ -91,7 +93,7 @@ func MapWatchEvent(event *privatev1.Event, mapper ResourceMapper, stateCtx *Stat CurrentState: mapper.CurrentState(), TransitionTime: transitionTime.Format(time.RFC3339Nano), DurationSeconds: durationPtr, - BillingDimensions: mapper.BillingDimensionsMap(), + BillingDimensions: billingDims, SchemaVersion: "v1", } if err := ce.SetData(cloudevents.ApplicationJSON, data); err != nil { diff --git a/osac-metering/metering-service/internal/events/mapper_test.go b/osac-metering/metering-service/internal/events/mapper_test.go index c59bab24f..cd8f15698 100644 --- a/osac-metering/metering-service/internal/events/mapper_test.go +++ b/osac-metering/metering-service/internal/events/mapper_test.go @@ -19,7 +19,7 @@ func mapEvent(event *privatev1.Event, stateCtx *events.StateContext) (*cloudeven if err != nil { return nil, err } - return events.MapWatchEvent(event, mapper, stateCtx) + return events.MapWatchEvent(event, mapper, stateCtx, mapper.BillingDimensionsMap()) } var _ = Describe("MapWatchEvent", func() { diff --git a/osac-metering/metering-service/internal/events/transitions.go b/osac-metering/metering-service/internal/events/transitions.go index 99e9816d3..bab997ac5 100644 --- a/osac-metering/metering-service/internal/events/transitions.go +++ b/osac-metering/metering-service/internal/events/transitions.go @@ -149,46 +149,3 @@ func BuildResourceEvents(resourceType string, dims map[string]any, baseID string } return decomposer(dims, baseID, buildFn) } - -// DimensionChangeHandler builds CloudEvents for billing dimension changes. -type DimensionChangeHandler func(oldDims, newDims map[string]any, baseID string, buildFn EventBuilder) ([]cloudevents.Event, error) - -var dimensionChangeHandlers = map[string]DimensionChangeHandler{ - ResourceTypeComputeInstance: singleDimensionChange, - ResourceTypeClusterOrder: componentDimensionChange, -} - -// BuildDimensionChangeEvents dispatches dimension change event building to -// the correct handler for the given resource type. VMaaS emits a single -// updated.v1 with the new dimensions; CaaS emits per-changed-component events. -func BuildDimensionChangeEvents(resourceType string, oldDims, newDims map[string]any, baseID string, buildFn EventBuilder) ([]cloudevents.Event, error) { - handler, ok := dimensionChangeHandlers[resourceType] - if !ok { - return nil, fmt.Errorf("unknown resource type for dimension change: %s", resourceType) - } - return handler(oldDims, newDims, baseID, buildFn) -} - -func singleDimensionChange(_, newDims map[string]any, baseID string, buildFn EventBuilder) ([]cloudevents.Event, error) { - ce, err := buildFn(newDims, baseID) - if err != nil { - return nil, err - } - return []cloudevents.Event{ce}, nil -} - -func componentDimensionChange(oldDims, newDims map[string]any, baseID string, buildFn EventBuilder) ([]cloudevents.Event, error) { - changed := ChangedComponents(oldDims, newDims) - if len(changed) == 0 { - return nil, nil - } - result := make([]cloudevents.Event, 0, len(changed)) - for _, comp := range changed { - ce, err := buildFn(comp.FlatBillingDimensions(), ComponentEventID(baseID, comp)) - if err != nil { - return nil, err - } - result = append(result, ce) - } - return result, nil -} diff --git a/osac-metering/metering-service/internal/events/transitions_test.go b/osac-metering/metering-service/internal/events/transitions_test.go index 12e6388f5..61396299d 100644 --- a/osac-metering/metering-service/internal/events/transitions_test.go +++ b/osac-metering/metering-service/internal/events/transitions_test.go @@ -222,73 +222,6 @@ var _ = Describe("BuildResourceEvents", func() { }) }) -var _ = Describe("BuildDimensionChangeEvents", func() { - simpleBuildFn := func(dims map[string]any, eventID string) (cloudevents.Event, error) { - ce := cloudevents.NewEvent() - ce.SetID(eventID) - if err := ce.SetData(cloudevents.ApplicationJSON, dims); err != nil { - return cloudevents.Event{}, fmt.Errorf("setting data: %w", err) - } - return ce, nil - } - - It("returns a single event for compute_instance (VMaaS)", func() { - oldDims := map[string]any{"instance_type": "m5.large"} - newDims := map[string]any{"instance_type": "m5.xlarge"} - result, err := events.BuildDimensionChangeEvents( - events.ResourceTypeComputeInstance, oldDims, newDims, "evt-ci-1", simpleBuildFn, - ) - Expect(err).NotTo(HaveOccurred()) - Expect(result).To(HaveLen(1)) - Expect(result[0].ID()).To(Equal("evt-ci-1")) - }) - - It("returns per-changed-component events for cluster_order (CaaS)", func() { - oldDims := map[string]any{ - "cluster_template": "tmpl", - "components": []any{ - map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, - map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, - }, - } - newDims := map[string]any{ - "cluster_template": "tmpl", - "components": []any{ - map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, - map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": int32(4)}, - }, - } - result, err := events.BuildDimensionChangeEvents( - events.ResourceTypeClusterOrder, oldDims, newDims, "evt-cl-1", simpleBuildFn, - ) - Expect(err).NotTo(HaveOccurred()) - Expect(result).To(HaveLen(1)) - Expect(result[0].ID()).To(Equal("evt-cl-1/gpu-workers")) - }) - - It("returns nil for cluster_order with no changes", func() { - dims := map[string]any{ - "cluster_template": "tmpl", - "components": []any{ - map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, - }, - } - result, err := events.BuildDimensionChangeEvents( - events.ResourceTypeClusterOrder, dims, dims, "evt-cl-1", simpleBuildFn, - ) - Expect(err).NotTo(HaveOccurred()) - Expect(result).To(BeNil()) - }) - - It("returns error for unknown resource type", func() { - _, err := events.BuildDimensionChangeEvents( - "unknown_resource", map[string]any{}, map[string]any{}, "evt-1", simpleBuildFn, - ) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("unknown resource type for dimension change")) - }) -}) - var _ = Describe("resolveTransition (indirect via ResolveCloudEventType)", func() { It("returns correct event type for an exact table match", func() { table := events.TransitionTable{ diff --git a/osac-metering/metering-service/internal/watch/consumer.go b/osac-metering/metering-service/internal/watch/consumer.go index 1de7f7d79..ddc91121b 100644 --- a/osac-metering/metering-service/internal/watch/consumer.go +++ b/osac-metering/metering-service/internal/watch/consumer.go @@ -159,13 +159,20 @@ func (c *Consumer) handleEvent(ctx context.Context, event *privatev1.Event) erro return err } - if c.shouldSkipUpdate(event, existing, currentState, dims, transitionTime, resourceID) { + if c.shouldSkipUpdate(ctx, event, existing, currentState, dims, version, transitionTime, resourceID) { return nil } stateCtx := c.buildStateContext(existing, isBillable, transitionTime, dims) - ce, err := events.MapWatchEvent(event, mapper, stateCtx) + eventDims := dims + if mapper.ResourceType() == events.ResourceTypeClusterOrder && + (event.GetType() == privatev1.EventType_EVENT_TYPE_OBJECT_CREATED || + event.GetType() == privatev1.EventType_EVENT_TYPE_OBJECT_DELETED) { + eventDims = topLevelDims(dims) + } + + ce, err := events.MapWatchEvent(event, mapper, stateCtx, eventDims) if err != nil { if errors.Is(err, events.ErrTransientState) { return c.handleTransientState(ctx, mapper, existing, version, transitionTime) @@ -264,9 +271,6 @@ const DimComponents = "components" func (c *Consumer) publishLifecycleEvents(ctx context.Context, baseCE *cloudevents.Event, mapper events.ResourceMapper, eventID string) error { if baseCE.Type() == events.EventCreated || baseCE.Type() == events.EventDeleted { - if mapper.ResourceType() == events.ResourceTypeClusterOrder { - c.stripComponentsFromAuditEvent(baseCE) - } return c.publishWithRetry(ctx, baseCE) } @@ -290,50 +294,53 @@ func (c *Consumer) handleScalingEvent(ctx context.Context, event *privatev1.Even stateCtx := c.buildStateContext(existing, isBillable, transitionTime, dims) return c.publishAndUpsert(ctx, func() error { - scalingEvents, err := events.BuildDimensionChangeEvents( - mapper.ResourceType(), existing.BillingDimensions, dims, event.GetId(), - func(d map[string]any, eventID string) (cloudevents.Event, error) { - return c.buildScalingEvent(eventID, mapper, d, stateCtx, transitionTime) - }) - if err != nil { - return err - } - if len(scalingEvents) == 0 { - c.logger.V(1).Info("non-component dimension change, projection updated", - "resource_id", resourceID) + if mapper.ResourceType() == events.ResourceTypeClusterOrder { + changed := events.ChangedComponents(existing.BillingDimensions, dims) + if len(changed) == 0 { + c.logger.V(1).Info("non-component dimension change, projection updated", + "resource_id", resourceID) + return nil + } + for _, comp := range changed { + scalingCtx := stateCtx + if comp.IsNew { + scalingCtx = &events.StateContext{ + PreviousState: stateCtx.PreviousState, + WasBillable: stateCtx.WasBillable, + NewDimensions: stateCtx.NewDimensions, + } + } + ce, ceErr := c.buildScalingEvent( + events.ComponentEventID(event.GetId(), comp), + mapper, comp.FlatBillingDimensions(), scalingCtx, transitionTime) + if ceErr != nil { + return ceErr + } + if err := c.publishWithRetry(ctx, &ce); err != nil { + return err + } + } + c.logger.Info("published scaling events", + "resource_id", resourceID, "changed_components", len(changed)) return nil } - for i := range scalingEvents { - if err := c.publishWithRetry(ctx, &scalingEvents[i]); err != nil { - return err - } + // VMaaS: single updated.v1 + ce, ceErr := c.buildScalingEvent(event.GetId(), mapper, dims, stateCtx, transitionTime) + if ceErr != nil { + return ceErr } - c.logger.Info("published scaling events", - "resource_id", resourceID, "changed_components", len(scalingEvents)) - return nil + return c.publishWithRetry(ctx, &ce) }, projState, resourceID) } -// stripComponentsFromAuditEvent removes the nested "components" array from -// cluster_order created.v1/deleted.v1 events so the audit payload has flat -// billing_dimensions (just cluster_template, release_image). -func (c *Consumer) stripComponentsFromAuditEvent(ce *cloudevents.Event) { - var data map[string]any - if err := ce.DataAs(&data); err != nil { - return - } - bd, ok := data["billing_dimensions"].(map[string]any) - if !ok { - return - } - flatDims := make(map[string]any, len(bd)) - for k, v := range bd { +func topLevelDims(dims map[string]any) map[string]any { + flat := make(map[string]any, len(dims)) + for k, v := range dims { if k != DimComponents { - flatDims[k] = v + flat[k] = v } } - data["billing_dimensions"] = flatDims - _ = ce.SetData(cloudevents.ApplicationJSON, data) + return flat } func (c *Consumer) buildComponentEvent(baseCE *cloudevents.Event, eventID string, dims map[string]any) (cloudevents.Event, error) { @@ -396,13 +403,20 @@ func (c *Consumer) buildScalingEvent(eventID string, mapper events.ResourceMappe } return ce, nil } -func (c *Consumer) shouldSkipUpdate(event *privatev1.Event, existing *projection.ResourceState, currentState string, dims map[string]any, transitionTime time.Time, resourceID string) bool { +func (c *Consumer) shouldSkipUpdate(ctx context.Context, event *privatev1.Event, existing *projection.ResourceState, currentState string, dims map[string]any, version int32, transitionTime time.Time, resourceID string) bool { if event.GetType() != privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED || existing == nil { return false } if existing.CurrentState != currentState || !events.DimensionsEqual(existing.BillingDimensions, dims) { return false } + if version > existing.FulfillmentVersion { + existing.FulfillmentVersion = version + existing.TransitionTime = transitionTime.UTC() + if err := c.store.Upsert(ctx, *existing); err != nil && !errors.Is(err, projection.ErrStaleVersion) { + c.logger.Error(err, "failed to advance projection version", "resource_id", resourceID) + } + } if !existing.TransitionTime.Truncate(time.Microsecond).Equal(transitionTime.UTC().Truncate(time.Microsecond)) { c.logger.Info("skipping replayed event (upserted but likely unpublished)", "resource_id", resourceID, "state", currentState) diff --git a/osac-metering/metering-service/internal/watch/consumer_test.go b/osac-metering/metering-service/internal/watch/consumer_test.go index c2ba8dca0..86b634927 100644 --- a/osac-metering/metering-service/internal/watch/consumer_test.go +++ b/osac-metering/metering-service/internal/watch/consumer_test.go @@ -1366,6 +1366,170 @@ var _ = Describe("Consumer", func() { Expect(data["duration_seconds"]).ToNot(BeNil()) }) + It("sets duration_seconds=nil for newly-added component (no prior billing interval)", func() { + store := newMockStore() + billableStart := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + store.states["cl-add"] = projection.ResourceState{ + ResourceID: "cl-add", + ResourceType: events.ResourceTypeClusterOrder, + TenantID: "tenant-1", + CurrentState: "READY", + IsBillable: true, + BillableSince: &billableStart, + FulfillmentVersion: 1, + BillingDimensions: map[string]any{ + "cluster_template": "tmpl", + "components": []any{ + map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + }, + }, + TransitionTime: billableStart, + } + + addedNodeSets := map[string]*privatev1.ClusterNodeSet{ + "tpu-workers": {HostType: &privatev1.HostTypeReference{Name: "tpu-v5"}, Size: 2}, + } + cl := makeCluster("cl-add", "tenant-1", privatev1.ClusterState_CLUSTER_STATE_READY, addedNodeSets) + event := &privatev1.Event{ + Id: "evt-add-component", + Type: privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, + Payload: &privatev1.Event_Cluster{Cluster: cl}, + } + + stream := &mockWatchStream{ + responses: []*privatev1.EventsWatchResponse{makeResponse(event)}, + } + client.results = []mockStreamResult{{stream: stream}} + + pub := &mockPublisher{published: make([]cloudevents.Event, 0, 1), cancelFunc: cancel} + consumer := newConsumerWithStore(pub, store) + + err := consumer.Run(ctx) + Expect(err).ToNot(HaveOccurred()) + + pub.mu.Lock() + defer pub.mu.Unlock() + Expect(pub.published).To(HaveLen(1)) + + var data map[string]any + Expect(json.Unmarshal(pub.published[0].Data(), &data)).To(Succeed()) + bd := data["billing_dimensions"].(map[string]any) + Expect(bd["node_set"]).To(Equal("tpu-workers")) + Expect(data["duration_seconds"]).To(BeNil(), + "newly-added component has no prior billing interval, duration must be nil") + }) + + It("sets duration for modified component and nil for new component in same scaling event", func() { + store := newMockStore() + billableStart := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + store.states["cl-mixed"] = projection.ResourceState{ + ResourceID: "cl-mixed", + ResourceType: events.ResourceTypeClusterOrder, + TenantID: "tenant-1", + CurrentState: "READY", + IsBillable: true, + BillableSince: &billableStart, + FulfillmentVersion: 1, + BillingDimensions: map[string]any{ + "cluster_template": "ocp-ci-small", + "components": []any{ + map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, + }, + }, + TransitionTime: billableStart, + } + + mixedNodeSets := map[string]*privatev1.ClusterNodeSet{ + "gpu-workers": {HostType: &privatev1.HostTypeReference{Name: "gpu-h100"}, Size: 4}, + "tpu-workers": {HostType: &privatev1.HostTypeReference{Name: "tpu-v5"}, Size: 2}, + } + cl := makeCluster("cl-mixed", "tenant-1", privatev1.ClusterState_CLUSTER_STATE_READY, mixedNodeSets) + event := &privatev1.Event{ + Id: "evt-mixed", + Type: privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, + Payload: &privatev1.Event_Cluster{Cluster: cl}, + } + + stream := &mockWatchStream{ + responses: []*privatev1.EventsWatchResponse{makeResponse(event)}, + } + client.results = []mockStreamResult{{stream: stream}} + + pub := &mockPublisher{published: make([]cloudevents.Event, 0, 2), cancelFunc: cancel} + consumer := newConsumerWithStore(pub, store) + + err := consumer.Run(ctx) + Expect(err).ToNot(HaveOccurred()) + + pub.mu.Lock() + defer pub.mu.Unlock() + Expect(pub.published).To(HaveLen(2)) + + eventsByNodeSet := map[string]map[string]any{} + for _, e := range pub.published { + var data map[string]any + Expect(json.Unmarshal(e.Data(), &data)).To(Succeed()) + bd := data["billing_dimensions"].(map[string]any) + eventsByNodeSet[bd["node_set"].(string)] = data + } + + Expect(eventsByNodeSet).To(HaveKey("gpu-workers")) + Expect(eventsByNodeSet["gpu-workers"]["duration_seconds"]).ToNot(BeNil(), + "modified component should have duration_seconds (closes prior billing interval)") + + Expect(eventsByNodeSet).To(HaveKey("tpu-workers")) + Expect(eventsByNodeSet["tpu-workers"]["duration_seconds"]).To(BeNil(), + "newly-added component should have nil duration_seconds (no prior interval)") + }) + + It("advances projection version on same-state-same-dims update with higher version", func() { + store := newMockStore() + now := time.Now().UTC().Truncate(time.Microsecond) + store.states["vm-version"] = projection.ResourceState{ + ResourceID: "vm-version", + ResourceType: events.ResourceTypeComputeInstance, + TenantID: "tenant-1", + CurrentState: "RUNNING", + IsBillable: true, + FulfillmentVersion: 5, + BillingDimensions: map[string]any{}, + TransitionTime: now, + } + + ci := makeComputeInstance("vm-version", "tenant-1") + ci.Metadata.Version = 10 + ci.Status.StateTransitionTime = timestamppb.New(now) + event := &privatev1.Event{ + Id: "evt-version-bump", + Type: privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, + Payload: &privatev1.Event_ComputeInstance{ComputeInstance: ci}, + } + + stream := &mockWatchStream{ + responses: []*privatev1.EventsWatchResponse{makeResponse(event)}, + } + client.results = []mockStreamResult{{stream: stream}} + + pub := &mockPublisher{cancelFunc: cancel} + consumer := newConsumerWithStore(pub, store) + + done := make(chan error, 1) + go func() { done <- consumer.Run(ctx) }() + time.Sleep(50 * time.Millisecond) + cancel() + Eventually(done, time.Second).Should(Receive(BeNil())) + + pub.mu.Lock() + defer pub.mu.Unlock() + Expect(pub.published).To(BeEmpty(), "same state+dims should not publish") + + store.mu.Lock() + defer store.mu.Unlock() + Expect(store.states["vm-version"].FulfillmentVersion).To(Equal(int32(10)), + "version should be advanced even when event is skipped") + }) + It("publishes exactly 1 event for cluster DELETED (not N+1)", func() { store := newMockStore() now := time.Now().UTC().Truncate(time.Microsecond) From 655c4dba07e9939446c265a5b54f2db818947381 Mon Sep 17 00:00:00 2001 From: omer-vishlitzky Date: Sun, 9 Aug 2026 12:42:41 +0300 Subject: [PATCH 17/18] fix: close dimension-drift, ID-collision, and validation gaps from review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChangedComponents compared only NodeCount and HostType, so a cluster upgrade (release_image change) or template change with unchanged topology was silently dropped: no updated.v1 published, and the projection still absorbed the new dimensions unconditionally in handleScalingEvent — which permanently defeats the reconciler's billing_dimensions_drift safety net, since the projection now matches fulfillment and no drift is ever detected. Fixed by comparing via DimensionsEqual on FlatBillingDimensions, the same equality already used to gate entry into this code path, so the two can't disagree. Two ID-derivation bugs meant retries didn't dedup the way CAP-16 ("duplicate events do not cause double-counting") requires: - Correction event IDs were derived from resourceID/reason/states only, with no discriminator. Two distinct billing_dimensions_drift corrections for the same resource/state collided on ID, so adapter dedup silently dropped the second one. Now includes a content fingerprint of the billing dimensions, so distinct drifts get distinct IDs while repeat detection of the *same* unresolved drift still dedups as designed. - Reconciler's synthetic heartbeat IDs were keyed off "now", so a retry in a later reconciliation cycle for the same unresolved staleness gap minted a fresh ID for components already delivered in a prior partial failure. Now keyed off LastHeartbeatAt/BillableSince — the actual reference point for the gap — so retries of the same gap dedup. Heartbeat Generator: base ID now keyed to the heartbeat window instead of a random UUID (consistency/future-proofing for the ID scheme, not an active bug — each real tick is a legitimately new heartbeat either way). More importantly, one resource's publish failure no longer aborts the whole tick; it's isolated so other resources still get heartbeated. Also: - buildComponentEvent guards against a nil base-event data map (DataAs leaves the target nil, not an error, when data is empty) - node_count is validated (integral, non-negative, in int32 range) before narrowing; a corrupt value now fails the whole decomposition loudly instead of either silently truncating/wrapping the billed value or (an earlier draft of this fix) silently dropping just the bad component, which would have been a new, differently-shaped silent data loss - Unified the duplicated lifecycle/scaling event payload construction (meteringData / buildScalingEvent's hand-rolled map) into one exported LifecycleData/BuildLifecycleData used by both, so the two producers can't drift into different shapes on the same topic - Removed the dead StateContext.NewDimensions field (assigned, never read) Assisted-by: Claude Code --- .../internal/events/cluster.go | 44 ++++-- .../internal/events/cluster_test.go | 136 ++++++++++++++++-- .../internal/events/mapper.go | 52 ++++--- .../internal/heartbeat/generator.go | 49 ++++--- .../heartbeat/generator_internal_test.go | 64 +++++++++ .../internal/heartbeat/generator_test.go | 48 ++++++- .../internal/reconciliation/correction.go | 23 ++- .../correction_internal_test.go | 128 +++++++++++++++++ .../internal/reconciliation/reconciler.go | 18 ++- .../internal/watch/consumer.go | 30 ++-- .../internal/watch/consumer_internal_test.go | 39 +++++ .../internal/watch/consumer_test.go | 4 +- 12 files changed, 537 insertions(+), 98 deletions(-) create mode 100644 osac-metering/metering-service/internal/heartbeat/generator_internal_test.go create mode 100644 osac-metering/metering-service/internal/watch/consumer_internal_test.go diff --git a/osac-metering/metering-service/internal/events/cluster.go b/osac-metering/metering-service/internal/events/cluster.go index fc5c6c6a6..985cf8e2b 100644 --- a/osac-metering/metering-service/internal/events/cluster.go +++ b/osac-metering/metering-service/internal/events/cluster.go @@ -11,6 +11,7 @@ package events import ( "fmt" + "math" "sort" "strings" "time" @@ -254,19 +255,22 @@ func (cr ComponentRecord) FlatBillingDimensions() map[string]any { // DecomposeClusterComponents extracts N+1 component records from stored // billing dimensions. Used by Watch Consumer, Heartbeat Generator, and -// Reconciler to fan out one cluster into per-component events. -func DecomposeClusterComponents(billingDims map[string]any) []ComponentRecord { +// Reconciler to fan out one cluster into per-component events. Returns +// ErrDataQuality if any component's node_count is corrupt — the caller must +// not proceed on a partial or wrong decomposition (fail fast, consistent +// with every other data-quality check in this package). +func DecomposeClusterComponents(billingDims map[string]any) ([]ComponentRecord, error) { clusterTemplate, _ := billingDims["cluster_template"].(string) releaseImage, _ := billingDims[DimensionReleaseImage].(string) componentsRaw, ok := billingDims["components"] if !ok { - return nil + return nil, fmt.Errorf("%w: billing dimensions have no components", ErrDataQuality) } components, ok := componentsRaw.([]any) if !ok { - return nil + return nil, fmt.Errorf("%w: billing dimensions components is not a list (got %T)", ErrDataQuality, componentsRaw) } records := make([]ComponentRecord, 0, len(components)) @@ -281,6 +285,9 @@ func DecomposeClusterComponents(billingDims map[string]any) []ComponentRecord { var nodeCount int32 if nc, ok := toFloat64(cm["node_count"]); ok { + if nc != math.Trunc(nc) || nc < 0 || nc > math.MaxInt32 { + return nil, fmt.Errorf("%w: node_count %v for node_set %q is not a valid non-negative int32", ErrDataQuality, nc, nodeSet) + } nodeCount = int32(nc) } @@ -294,7 +301,7 @@ func DecomposeClusterComponents(billingDims map[string]any) []ComponentRecord { }) } - return records + return records, nil } // ComponentEventID derives a deterministic CloudEvent ID for a decomposed @@ -307,7 +314,10 @@ func ComponentEventID(baseEventID string, comp ComponentRecord) string { // Returns error if billing dimensions have no components (data quality issue). // buildFn receives (per-component billing dimensions, deterministic event ID). func DecomposeClusterEvents(billingDims map[string]any, baseID string, buildFn EventBuilder) ([]cloudevents.Event, error) { - components := DecomposeClusterComponents(billingDims) + components, err := DecomposeClusterComponents(billingDims) + if err != nil { + return nil, err + } if len(components) == 0 { return nil, fmt.Errorf("%w: cluster has no components in billing dimensions", ErrDataQuality) } @@ -324,11 +334,21 @@ func DecomposeClusterEvents(billingDims map[string]any, baseID string, buildFn E } // ChangedComponents compares old and new billing dimensions and returns -// component records that changed: node_count differs, newly added, or removed. +// component records that changed — including newly added or removed node +// sets. "Changed" is defined as "the component's billing-relevant wire +// representation differs" (via FlatBillingDimensions + DimensionsEqual), +// the same equality used to gate entry into this comparison in the first +// place — so this can never miss a field DimensionsEqual would catch. // Removed components are returned with NodeCount=0. -func ChangedComponents(oldDims, newDims map[string]any) []ComponentRecord { - oldRecords := DecomposeClusterComponents(oldDims) - newRecords := DecomposeClusterComponents(newDims) +func ChangedComponents(oldDims, newDims map[string]any) ([]ComponentRecord, error) { + oldRecords, err := DecomposeClusterComponents(oldDims) + if err != nil { + return nil, err + } + newRecords, err := DecomposeClusterComponents(newDims) + if err != nil { + return nil, err + } oldByKey := make(map[string]ComponentRecord, len(oldRecords)) for _, r := range oldRecords { @@ -340,7 +360,7 @@ func ChangedComponents(oldDims, newDims map[string]any) []ComponentRecord { for _, r := range newRecords { newByKey[r.NodeSet] = true old, exists := oldByKey[r.NodeSet] - if !exists || old.NodeCount != r.NodeCount || old.HostType != r.HostType { + if !exists || !DimensionsEqual(old.FlatBillingDimensions(), r.FlatBillingDimensions()) { r.IsNew = !exists changed = append(changed, r) } @@ -359,5 +379,5 @@ func ChangedComponents(oldDims, newDims map[string]any) []ComponentRecord { } } - return changed + return changed, nil } diff --git a/osac-metering/metering-service/internal/events/cluster_test.go b/osac-metering/metering-service/internal/events/cluster_test.go index f628007a1..961913dc9 100644 --- a/osac-metering/metering-service/internal/events/cluster_test.go +++ b/osac-metering/metering-service/internal/events/cluster_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "math" cloudevents "github.com/cloudevents/sdk-go/v2" . "github.com/onsi/ginkgo/v2" @@ -352,7 +353,8 @@ var _ = Describe("CaaS Cluster Mapper", func() { It("DecomposeClusterComponents works on fresh (non-JSONB) output", func() { dims := events.ClusterBillingDimensions(cl) - records := events.DecomposeClusterComponents(dims) + records, err := events.DecomposeClusterComponents(dims) + Expect(err).NotTo(HaveOccurred()) Expect(records).To(HaveLen(3)) Expect(records[0].NodeSet).To(Equal("_control_plane")) Expect(records[0].Component).To(Equal("control_plane")) @@ -524,7 +526,8 @@ var _ = Describe("DecomposeClusterComponents", func() { }, } - records := events.DecomposeClusterComponents(dims) + records, err := events.DecomposeClusterComponents(dims) + Expect(err).NotTo(HaveOccurred()) Expect(records).To(HaveLen(3)) Expect(records[0].NodeSet).To(Equal("_control_plane")) @@ -554,19 +557,70 @@ var _ = Describe("DecomposeClusterComponents", func() { }, } - records := events.DecomposeClusterComponents(dims) + records, err := events.DecomposeClusterComponents(dims) + Expect(err).NotTo(HaveOccurred()) Expect(records).To(HaveLen(2)) Expect(records[0].NodeCount).To(Equal(int32(1))) Expect(records[1].NodeCount).To(Equal(int32(2))) }) - It("returns nil when no components key", func() { + It("returns ErrDataQuality when no components key", func() { dims := map[string]any{"cluster_template": "tmpl"} - Expect(events.DecomposeClusterComponents(dims)).To(BeNil()) + _, err := events.DecomposeClusterComponents(dims) + Expect(errors.Is(err, events.ErrDataQuality)).To(BeTrue()) + }) + + It("returns ErrDataQuality for empty dims", func() { + _, err := events.DecomposeClusterComponents(map[string]any{}) + Expect(errors.Is(err, events.ErrDataQuality)).To(BeTrue()) + }) + + It("rejects a component with fractional node_count instead of billing a truncated value", func() { + dims := map[string]any{ + "cluster_template": "tmpl", + "components": []any{ + map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": 2.7}, + }, + } + _, err := events.DecomposeClusterComponents(dims) + Expect(errors.Is(err, events.ErrDataQuality)).To(BeTrue(), + "a corrupt fractional node_count must not silently produce a truncated billed value") + }) + + It("rejects a component with negative node_count", func() { + dims := map[string]any{ + "cluster_template": "tmpl", + "components": []any{ + map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": -1.0}, + }, + } + _, err := events.DecomposeClusterComponents(dims) + Expect(errors.Is(err, events.ErrDataQuality)).To(BeTrue()) }) - It("returns nil for empty dims", func() { - Expect(events.DecomposeClusterComponents(map[string]any{})).To(BeNil()) + It("rejects a component with node_count exceeding int32 range", func() { + dims := map[string]any{ + "cluster_template": "tmpl", + "components": []any{ + map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": float64(math.MaxInt32) + 1}, + }, + } + _, err := events.DecomposeClusterComponents(dims) + Expect(errors.Is(err, events.ErrDataQuality)).To(BeTrue(), + "an out-of-range node_count must not silently wrap to a negative or truncated billed value") + }) + + It("rejects the whole decomposition when one sibling's node_count is corrupt, rather than leaking partial results", func() { + dims := map[string]any{ + "cluster_template": "tmpl", + "components": []any{ + map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, + map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": -1.0}, + }, + } + records, err := events.DecomposeClusterComponents(dims) + Expect(errors.Is(err, events.ErrDataQuality)).To(BeTrue()) + Expect(records).To(BeNil(), "a corrupt component must fail the whole decomposition, not leak partial results for its healthy siblings") }) }) @@ -642,7 +696,8 @@ var _ = Describe("ChangedComponents", func() { }, } - changed := events.ChangedComponents(oldDims, newDims) + changed, err := events.ChangedComponents(oldDims, newDims) + Expect(err).NotTo(HaveOccurred()) Expect(changed).To(HaveLen(1)) Expect(changed[0].HostType).To(Equal("gpu-h100")) Expect(changed[0].NodeCount).To(Equal(int32(4))) @@ -656,7 +711,9 @@ var _ = Describe("ChangedComponents", func() { }, } - Expect(events.ChangedComponents(dims, dims)).To(BeEmpty()) + changed, err := events.ChangedComponents(dims, dims) + Expect(err).NotTo(HaveOccurred()) + Expect(changed).To(BeEmpty()) }) It("detects newly added worker node set", func() { @@ -674,7 +731,8 @@ var _ = Describe("ChangedComponents", func() { }, } - changed := events.ChangedComponents(oldDims, newDims) + changed, err := events.ChangedComponents(oldDims, newDims) + Expect(err).NotTo(HaveOccurred()) Expect(changed).To(HaveLen(1)) Expect(changed[0].HostType).To(Equal("gpu-h100")) }) @@ -694,7 +752,8 @@ var _ = Describe("ChangedComponents", func() { }, } - changed := events.ChangedComponents(oldDims, newDims) + changed, err := events.ChangedComponents(oldDims, newDims) + Expect(err).NotTo(HaveOccurred()) Expect(changed).To(HaveLen(1)) Expect(changed[0].HostType).To(Equal("gpu-h100")) Expect(changed[0].NodeCount).To(Equal(int32(0))) @@ -717,7 +776,8 @@ var _ = Describe("ChangedComponents", func() { }, } - changed := events.ChangedComponents(oldDims, newDims) + changed, err := events.ChangedComponents(oldDims, newDims) + Expect(err).NotTo(HaveOccurred()) Expect(changed).To(HaveLen(2)) nodeSetToHostType := map[string]string{} @@ -750,7 +810,8 @@ var _ = Describe("ChangedComponents", func() { }, } - changed := events.ChangedComponents(oldDims, newDims) + changed, err := events.ChangedComponents(oldDims, newDims) + Expect(err).NotTo(HaveOccurred()) Expect(changed).To(HaveLen(2)) byNodeSet := map[string]events.ComponentRecord{} @@ -780,12 +841,55 @@ var _ = Describe("ChangedComponents", func() { }, } - changed := events.ChangedComponents(oldDims, newDims) + changed, err := events.ChangedComponents(oldDims, newDims) + Expect(err).NotTo(HaveOccurred()) Expect(changed).To(HaveLen(1)) Expect(changed[0].HostType).To(Equal("gpu-a100")) Expect(changed[0].NodeCount).To(Equal(int32(2))) }) + It("detects cluster_template change with unchanged node_count and host_type", func() { + oldDims := map[string]any{ + "cluster_template": "ocp-ci-small", + "components": []any{ + map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, + }, + } + newDims := map[string]any{ + "cluster_template": "ocp-ci-large", + "components": []any{ + map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, + }, + } + + changed, err := events.ChangedComponents(oldDims, newDims) + Expect(err).NotTo(HaveOccurred()) + Expect(changed).To(HaveLen(1), "a cluster_template-only change must still be reported so it can be published") + Expect(changed[0].ClusterTemplate).To(Equal("ocp-ci-large")) + }) + + It("detects release_image change with unchanged node_count and host_type", func() { + oldDims := map[string]any{ + "cluster_template": "tmpl", + "release_image": "quay.io/openshift-release-dev/ocp-release:4.17.0-x86_64", + "components": []any{ + map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, + }, + } + newDims := map[string]any{ + "cluster_template": "tmpl", + "release_image": "quay.io/openshift-release-dev/ocp-release:4.18.0-x86_64", + "components": []any{ + map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, + }, + } + + changed, err := events.ChangedComponents(oldDims, newDims) + Expect(err).NotTo(HaveOccurred()) + Expect(changed).To(HaveLen(1), "a cluster upgrade (release_image-only change) must still be reported so it can be published") + Expect(changed[0].ReleaseImage).To(Equal("quay.io/openshift-release-dev/ocp-release:4.18.0-x86_64")) + }) + It("handles int32 vs float64 from JSONB round-trip", func() { oldDims := map[string]any{ "cluster_template": "tmpl", @@ -800,7 +904,9 @@ var _ = Describe("ChangedComponents", func() { }, } - Expect(events.ChangedComponents(oldDims, newDims)).To(BeEmpty()) + changed, err := events.ChangedComponents(oldDims, newDims) + Expect(err).NotTo(HaveOccurred()) + Expect(changed).To(BeEmpty()) }) }) diff --git a/osac-metering/metering-service/internal/events/mapper.go b/osac-metering/metering-service/internal/events/mapper.go index 226b18ecd..cdf5fd1e3 100644 --- a/osac-metering/metering-service/internal/events/mapper.go +++ b/osac-metering/metering-service/internal/events/mapper.go @@ -36,7 +36,6 @@ type StateContext struct { WasBillable bool BillableSince *time.Time DurationSeconds *float64 - NewDimensions map[string]any } // MapWatchEvent converts a fulfillment-service Watch Event into a CloudEvents 1.0 @@ -76,26 +75,7 @@ func MapWatchEvent(event *privatev1.Event, mapper ResourceMapper, stateCtx *Stat } SetOSACExtensions(&ce, mapper.ResourceID(), mapper.ResourceType(), mapper.TenantID(), projectID) - var prevStatePtr *string - if stateCtx.PreviousState != "" { - prevStatePtr = &stateCtx.PreviousState - } - durationPtr := stateCtx.DurationSeconds - - data := meteringData{ - ResourceID: mapper.ResourceID(), - ResourceType: mapper.ResourceType(), - TenantID: mapper.TenantID(), - ProjectID: mapper.ProjectID(), - CatalogItemID: mapper.CatalogItemID(), - TemplateID: mapper.TemplateID(), - PreviousState: prevStatePtr, - CurrentState: mapper.CurrentState(), - TransitionTime: transitionTime.Format(time.RFC3339Nano), - DurationSeconds: durationPtr, - BillingDimensions: billingDims, - SchemaVersion: "v1", - } + data := BuildLifecycleData(mapper, billingDims, stateCtx.PreviousState, stateCtx.DurationSeconds, transitionTime) if err := ce.SetData(cloudevents.ApplicationJSON, data); err != nil { return nil, fmt.Errorf("setting CloudEvent data: %w", err) } @@ -121,8 +101,11 @@ func mapperForEvent(event *privatev1.Event) (ResourceMapper, error) { return nil, fmt.Errorf("unsupported event payload type for event %s", event.GetId()) } -// meteringData is the shared JSON payload for all resource types. -type meteringData struct { +// LifecycleData is the shared JSON payload for lifecycle and scaling events +// across all resource types. Exported and built exclusively through +// BuildLifecycleData so every producer (MapWatchEvent, and the Watch +// Consumer's scaling-event builder) emits the identical shape. +type LifecycleData struct { ResourceID string `json:"resource_id"` ResourceType string `json:"resource_type"` TenantID string `json:"tenant_id"` @@ -137,6 +120,29 @@ type meteringData struct { SchemaVersion string `json:"schema_version"` } +// BuildLifecycleData constructs the shared lifecycle/scaling event payload +// from a resource mapper. +func BuildLifecycleData(mapper ResourceMapper, billingDims map[string]any, previousState string, durationSeconds *float64, transitionTime time.Time) LifecycleData { + var prevStatePtr *string + if previousState != "" { + prevStatePtr = &previousState + } + return LifecycleData{ + ResourceID: mapper.ResourceID(), + ResourceType: mapper.ResourceType(), + TenantID: mapper.TenantID(), + ProjectID: mapper.ProjectID(), + CatalogItemID: mapper.CatalogItemID(), + TemplateID: mapper.TemplateID(), + PreviousState: prevStatePtr, + CurrentState: mapper.CurrentState(), + TransitionTime: transitionTime.Format(time.RFC3339Nano), + DurationSeconds: durationSeconds, + BillingDimensions: billingDims, + SchemaVersion: "v1", + } +} + func NilIfEmpty(s string) *string { if s == "" { return nil diff --git a/osac-metering/metering-service/internal/heartbeat/generator.go b/osac-metering/metering-service/internal/heartbeat/generator.go index 2800811af..576fc0f1a 100644 --- a/osac-metering/metering-service/internal/heartbeat/generator.go +++ b/osac-metering/metering-service/internal/heartbeat/generator.go @@ -16,7 +16,6 @@ import ( cloudevents "github.com/cloudevents/sdk-go/v2" "github.com/go-logr/logr" - "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" @@ -92,9 +91,10 @@ func (g *Generator) tick(ctx context.Context) error { now := time.Now().UTC() var publishedIDs []string - // Partial checkpoint: on Kafka failure, already-published IDs are checkpointed - // to prevent duplicate heartbeats on retry. At scale (>10K VMs), consider - // Kafka transactional producer for atomic batch publish. + // One resource's publish failure is isolated to that resource: it is + // skipped for this tick (so its checkpoint does not advance and the + // reconciler's stale-heartbeat detection picks it back up), but it does + // not block heartbeats for the other resources in the same tick. for i := range billable { hbEvents, ceErr := g.buildHeartbeatEvents(&billable[i], now) if ceErr != nil { @@ -102,34 +102,47 @@ func (g *Generator) tick(ctx context.Context) error { "resource_id", billable[i].ResourceID) continue } - for j := range hbEvents { - if err := g.publisher.Publish(ctx, hbEvents[j]); err != nil { - if len(publishedIDs) > 0 { - if cpErr := g.store.UpdateLastHeartbeat(ctx, publishedIDs, now); cpErr != nil { - g.logger.Error(cpErr, "failed to checkpoint partial heartbeat progress", - "published", len(publishedIDs)) - } - } - return fmt.Errorf("publishing heartbeat for %s: %w", billable[i].ResourceID, err) - } + if !g.publishResourceHeartbeats(ctx, hbEvents, billable[i].ResourceID) { + continue } publishedIDs = append(publishedIDs, billable[i].ResourceID) } - if err := g.store.UpdateLastHeartbeat(ctx, publishedIDs, now); err != nil { - return fmt.Errorf("updating last heartbeat: %w", err) + if len(publishedIDs) > 0 { + if err := g.store.UpdateLastHeartbeat(ctx, publishedIDs, now); err != nil { + return fmt.Errorf("updating last heartbeat: %w", err) + } } - g.logger.Info("heartbeat tick completed", "count", len(publishedIDs)) + g.logger.Info("heartbeat tick completed", "published", len(publishedIDs), "total", len(billable)) return nil } +// publishResourceHeartbeats publishes every event in one resource's N+1 +// fan-out. A failure partway through means the resource is not checkpointed +// this tick, but it does not prevent other resources from heartbeating. +func (g *Generator) publishResourceHeartbeats(ctx context.Context, hbEvents []cloudevents.Event, resourceID string) bool { + for j := range hbEvents { + if err := g.publisher.Publish(ctx, hbEvents[j]); err != nil { + g.logger.Error(err, "publishing heartbeat, resource will not be checkpointed this tick", + "resource_id", resourceID) + return false + } + } + return true +} + func (g *Generator) buildHeartbeatEvents(state *projection.ResourceState, now time.Time) ([]cloudevents.Event, error) { buildFn := func(dims map[string]any, eventID string) (cloudevents.Event, error) { return g.buildHeartbeatEvent(state, eventID, dims, now) } - return events.BuildResourceEvents(state.ResourceType, state.BillingDimensions, uuid.NewString(), buildFn) + // Base ID should be reproducible for a given resource and heartbeat + // window, so that building this same tick's events more than once — + // e.g. a future in-tick retry — reproduces the same per-component + // CloudEvent IDs. + baseID := fmt.Sprintf("hb/%s/%d", state.ResourceID, now.Truncate(g.interval).Unix()) + return events.BuildResourceEvents(state.ResourceType, state.BillingDimensions, baseID, buildFn) } func (g *Generator) buildHeartbeatEvent(state *projection.ResourceState, eventID string, dims map[string]any, now time.Time) (cloudevents.Event, error) { diff --git a/osac-metering/metering-service/internal/heartbeat/generator_internal_test.go b/osac-metering/metering-service/internal/heartbeat/generator_internal_test.go new file mode 100644 index 000000000..9eb1fea3f --- /dev/null +++ b/osac-metering/metering-service/internal/heartbeat/generator_internal_test.go @@ -0,0 +1,64 @@ +/* +Copyright (c) 2026 Red Hat, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ + +package heartbeat + +import ( + "testing" + "time" + + "github.com/osac-project/osac-metering/internal/events" + "github.com/osac-project/osac-metering/internal/projection" +) + +func TestBuildHeartbeatEventsStableIDWithinSameWindow(t *testing.T) { + g := &Generator{interval: 60 * time.Second} + state := &projection.ResourceState{ + ResourceID: "vm-1", + ResourceType: events.ResourceTypeComputeInstance, + BillingDimensions: map[string]any{"instance_type": "m5.large"}, + } + + windowStart := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + first, err := g.buildHeartbeatEvents(state, windowStart.Add(5*time.Second)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + second, err := g.buildHeartbeatEvents(state, windowStart.Add(45*time.Second)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if first[0].ID() != second[0].ID() { + t.Errorf("two builds within the same %s heartbeat window should share a CloudEvent ID, got %q and %q", g.interval, first[0].ID(), second[0].ID()) + } +} + +func TestBuildHeartbeatEventsNewIDInNextWindow(t *testing.T) { + g := &Generator{interval: 60 * time.Second} + state := &projection.ResourceState{ + ResourceID: "vm-1", + ResourceType: events.ResourceTypeComputeInstance, + BillingDimensions: map[string]any{"instance_type": "m5.large"}, + } + + windowStart := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + first, err := g.buildHeartbeatEvents(state, windowStart) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + second, err := g.buildHeartbeatEvents(state, windowStart.Add(g.interval)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if first[0].ID() == second[0].ID() { + t.Errorf("builds in different heartbeat windows must not share a CloudEvent ID, both were %q", first[0].ID()) + } +} diff --git a/osac-metering/metering-service/internal/heartbeat/generator_test.go b/osac-metering/metering-service/internal/heartbeat/generator_test.go index 58992f520..7246a9f3d 100644 --- a/osac-metering/metering-service/internal/heartbeat/generator_test.go +++ b/osac-metering/metering-service/internal/heartbeat/generator_test.go @@ -54,17 +54,29 @@ func (s *mockStore) UpdateLastHeartbeat(_ context.Context, ids []string, at time } type mockPublisher struct { - mu sync.Mutex - published []cloudevents.Event - err error - failAfter int - callCount int + mu sync.Mutex + published []cloudevents.Event + err error + failAfter int + callCount int + failResourceID string // if set, Publish persistently fails for this resource's events only } func (p *mockPublisher) Publish(_ context.Context, event cloudevents.Event) error { p.mu.Lock() defer p.mu.Unlock() p.callCount++ + if p.failResourceID != "" { + // Self-contained mode: only this resource's events fail, regardless + // of failAfter/err, which drive the (mutually exclusive) call-count-based mode below. + if rid, err := event.Context.GetExtension("osacresourceid"); err == nil { + if s, ok := rid.(string); ok && s == p.failResourceID { + return p.err + } + } + p.published = append(p.published, event) + return nil + } if p.failAfter > 0 && p.callCount > p.failAfter { return p.err } @@ -194,6 +206,32 @@ var _ = Describe("Generator", func() { Expect(store.updatedIDs).To(HaveLen(2)) }) + It("still heartbeats other resources when one resource's publish persistently fails", func() { + store := &mockStore{ + billable: []projection.ResourceState{ + makeBillableState("vm-fail"), + makeBillableState("vm-ok"), + }, + } + pub := &mockPublisher{ + err: fmt.Errorf("kafka unavailable"), + failResourceID: "vm-fail", + } + gen := heartbeat.NewGenerator(store, pub, logr.Discard(), 100*time.Millisecond) + + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + + err := gen.Run(ctx) + Expect(err).ToNot(HaveOccurred()) + + store.mu.Lock() + defer store.mu.Unlock() + Expect(store.updatedIDs).To(ContainElement("vm-ok"), + "a resource whose publish fails must not block heartbeats for other resources in the same tick") + Expect(store.updatedIDs).NotTo(ContainElement("vm-fail")) + }) + It("fails tick when ListBillable returns error", func() { store := &mockStore{listErr: fmt.Errorf("database unavailable")} pub := &mockPublisher{} diff --git a/osac-metering/metering-service/internal/reconciliation/correction.go b/osac-metering/metering-service/internal/reconciliation/correction.go index 02cadadbb..3d9eee6a0 100644 --- a/osac-metering/metering-service/internal/reconciliation/correction.go +++ b/osac-metering/metering-service/internal/reconciliation/correction.go @@ -10,7 +10,9 @@ in compliance with the License. You may obtain a copy of the License at package reconciliation import ( + "encoding/json" "fmt" + "hash/fnv" "time" cloudevents "github.com/cloudevents/sdk-go/v2" @@ -73,7 +75,8 @@ func buildCorrectionEvents( interval *AffectedInterval, now time.Time, ) ([]cloudevents.Event, error) { - baseID := fmt.Sprintf("correction/%s/%s/%s/%s", resourceID, reason, projectionState, sourceState) + baseID := fmt.Sprintf("correction/%s/%s/%s/%s/%s", resourceID, reason, projectionState, sourceState, + correctionFingerprint(billingDimensions, interval)) buildFn := func(dims map[string]any, eventID string) (cloudevents.Event, error) { ce, err := buildCorrectionEvent(resourceID, resourceType, tenantID, projectID, reason, projectionState, sourceState, dims, interval, now) @@ -87,6 +90,24 @@ func buildCorrectionEvents( return events.BuildResourceEvents(resourceType, billingDimensions, baseID, buildFn) } +// correctionFingerprint discriminates corrections that share resourceID, +// reason, and states but describe different discrepancies (e.g. two separate +// billing_dimensions_drift detections for the same resource while it stays +// in the same state). Content-based rather than time-based: repeat detection +// of the SAME unresolved discrepancy across reconciliation cycles must keep +// producing the SAME ID so it dedups as the design intends ("duplicate +// corrections... are acceptable"), while a genuinely different discrepancy +// must not collide with a prior one and get silently dropped. +func correctionFingerprint(billingDimensions map[string]any, interval *AffectedInterval) string { + enc, _ := json.Marshal(struct { + Dims map[string]any `json:"dims"` + Interval *AffectedInterval `json:"interval,omitempty"` + }{billingDimensions, interval}) + h := fnv.New64a() + _, _ = h.Write(enc) + return fmt.Sprintf("%x", h.Sum64()) +} + func buildCorrectionEvent( resourceID, resourceType, tenantID, projectID string, reason CorrectionReason, diff --git a/osac-metering/metering-service/internal/reconciliation/correction_internal_test.go b/osac-metering/metering-service/internal/reconciliation/correction_internal_test.go index 0f963cfae..731a65f8b 100644 --- a/osac-metering/metering-service/internal/reconciliation/correction_internal_test.go +++ b/osac-metering/metering-service/internal/reconciliation/correction_internal_test.go @@ -11,6 +11,10 @@ package reconciliation import ( "testing" + "time" + + "github.com/osac-project/osac-metering/internal/events" + "github.com/osac-project/osac-metering/internal/projection" ) func TestCorrectionDescription(t *testing.T) { @@ -47,3 +51,127 @@ func TestCorrectionDescriptionUnknownReason(t *testing.T) { t.Errorf("expected error %q, got %q", expected, err.Error()) } } + +func TestBuildSyntheticHeartbeatsStableIDAcrossRetryOfSameGap(t *testing.T) { + lastHeartbeat := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + ps := projection.ResourceState{ + ResourceID: "res-1", + ResourceType: events.ResourceTypeComputeInstance, + LastHeartbeatAt: &lastHeartbeat, + BillingDimensions: map[string]any{"instance_type": "m5.large"}, + } + + firstAttempt := lastHeartbeat.Add(65 * time.Minute) + secondAttempt := lastHeartbeat.Add(125 * time.Minute) + + first, err := buildSyntheticHeartbeats(ps, firstAttempt) + if err != nil { + t.Fatalf("first attempt: unexpected error: %v", err) + } + second, err := buildSyntheticHeartbeats(ps, secondAttempt) + if err != nil { + t.Fatalf("second attempt: unexpected error: %v", err) + } + + if len(first) != 1 || len(second) != 1 { + t.Fatalf("expected 1 event per attempt, got %d and %d", len(first), len(second)) + } + if first[0].ID() != second[0].ID() { + t.Errorf("expected the same CloudEvent ID for two attempts at closing the same unresolved gap (LastHeartbeatAt unchanged), got %q and %q", first[0].ID(), second[0].ID()) + } +} + +func TestBuildSyntheticHeartbeatsNewIDOnceGapResolves(t *testing.T) { + // Same reconciliation run (same "now"); only LastHeartbeatAt differs. + // Isolates the ID's dependency on LastHeartbeatAt from any dependency on now. + now := time.Date(2026, 1, 1, 15, 0, 0, 0, time.UTC) + firstGap := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + secondGap := time.Date(2026, 1, 1, 14, 0, 0, 0, time.UTC) + + psBefore := projection.ResourceState{ + ResourceID: "res-1", + ResourceType: events.ResourceTypeComputeInstance, + LastHeartbeatAt: &firstGap, + BillingDimensions: map[string]any{"instance_type": "m5.large"}, + } + psAfter := psBefore + psAfter.LastHeartbeatAt = &secondGap + + before, err := buildSyntheticHeartbeats(psBefore, now) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + after, err := buildSyntheticHeartbeats(psAfter, now) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if before[0].ID() == after[0].ID() { + t.Errorf("expected a different CloudEvent ID once LastHeartbeatAt advances to a new gap, both were %q", before[0].ID()) + } +} + +func TestBuildCorrectionEventsDifferentDimensionsGetDifferentIDs(t *testing.T) { + now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + dimsA := map[string]any{"instance_type": "m5.large"} + dimsB := map[string]any{"instance_type": "m5.xlarge"} + + a, err := buildCorrectionEvents("res-1", events.ResourceTypeComputeInstance, "tenant-1", "", + BillingDimensionsDrift, "RUNNING", "RUNNING", dimsA, nil, now) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + b, err := buildCorrectionEvents("res-1", events.ResourceTypeComputeInstance, "tenant-1", "", + BillingDimensionsDrift, "RUNNING", "RUNNING", dimsB, nil, now) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if a[0].ID() == b[0].ID() { + t.Errorf("two distinct billing_dimensions_drift corrections (different dimensions) for the same resource/state must not share a CloudEvent ID, both were %q — the second would be silently dropped by adapter-side ID dedup", a[0].ID()) + } +} + +func TestBuildCorrectionEventsSameDimensionsGetSameID(t *testing.T) { + now1 := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + now2 := time.Date(2026, 1, 1, 13, 0, 0, 0, time.UTC) // a later reconciliation cycle re-detecting the same unresolved drift + dims := map[string]any{"instance_type": "m5.large"} + + a, err := buildCorrectionEvents("res-1", events.ResourceTypeComputeInstance, "tenant-1", "", + BillingDimensionsDrift, "RUNNING", "RUNNING", dims, nil, now1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + b, err := buildCorrectionEvents("res-1", events.ResourceTypeComputeInstance, "tenant-1", "", + BillingDimensionsDrift, "RUNNING", "RUNNING", dims, nil, now2) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if a[0].ID() != b[0].ID() { + t.Errorf("repeat detection of the SAME unresolved drift across reconciliation cycles should dedup (design.md: duplicate corrections for the same state are acceptable/harmless), got %q and %q", a[0].ID(), b[0].ID()) + } +} + +func TestBuildSyntheticHeartbeatsFallsBackToBillableSinceWhenNeverHeartbeated(t *testing.T) { + billableSince := time.Date(2026, 1, 1, 9, 0, 0, 0, time.UTC) + ps := projection.ResourceState{ + ResourceID: "res-never-hb", + ResourceType: events.ResourceTypeComputeInstance, + BillableSince: &billableSince, + BillingDimensions: map[string]any{"instance_type": "m5.large"}, + } + + first, err := buildSyntheticHeartbeats(ps, billableSince.Add(65*time.Minute)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + second, err := buildSyntheticHeartbeats(ps, billableSince.Add(125*time.Minute)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if first[0].ID() != second[0].ID() { + t.Errorf("expected a stable ID keyed off BillableSince when LastHeartbeatAt is nil, got %q and %q", first[0].ID(), second[0].ID()) + } +} diff --git a/osac-metering/metering-service/internal/reconciliation/reconciler.go b/osac-metering/metering-service/internal/reconciliation/reconciler.go index ef0510287..10778b3f2 100644 --- a/osac-metering/metering-service/internal/reconciliation/reconciler.go +++ b/osac-metering/metering-service/internal/reconciliation/reconciler.go @@ -477,7 +477,7 @@ func (r *Reconciler) loadClusters(ctx context.Context, result map[string]fulfill } func buildSyntheticHeartbeats(ps projection.ResourceState, now time.Time) ([]cloudevents.Event, error) { - baseID := fmt.Sprintf("synthetic-hb/%s/%d", ps.ResourceID, now.UTC().Unix()) + baseID := fmt.Sprintf("synthetic-hb/%s/%d", ps.ResourceID, staleReferencePoint(ps, now).Unix()) buildFn := func(dims map[string]any, eventID string) (cloudevents.Event, error) { return buildSingleSyntheticHeartbeat(ps, dims, eventID, now) } @@ -485,6 +485,22 @@ func buildSyntheticHeartbeats(ps projection.ResourceState, now time.Time) ([]clo return events.BuildResourceEvents(ps.ResourceType, ps.BillingDimensions, baseID, buildFn) } +// staleReferencePoint returns the timestamp identifying the billing gap a +// synthetic heartbeat is catching up on, rather than the moment reconciliation +// happened to run. Keying the CloudEvent base ID off this value means retries +// across reconciliation cycles for the SAME unresolved gap reproduce the SAME +// ID (so adapter-side ID dedup recognizes them as the same logical catch-up), +// while a genuinely new gap (LastHeartbeatAt has since advanced) gets a new one. +func staleReferencePoint(ps projection.ResourceState, now time.Time) time.Time { + if ps.LastHeartbeatAt != nil { + return *ps.LastHeartbeatAt + } + if ps.BillableSince != nil { + return *ps.BillableSince + } + return now +} + func buildSingleSyntheticHeartbeat(ps projection.ResourceState, billingDims map[string]any, eventID string, now time.Time) (cloudevents.Event, error) { ce := cloudevents.NewEvent() ce.SetID(eventID) diff --git a/osac-metering/metering-service/internal/watch/consumer.go b/osac-metering/metering-service/internal/watch/consumer.go index ddc91121b..b6e34e3e7 100644 --- a/osac-metering/metering-service/internal/watch/consumer.go +++ b/osac-metering/metering-service/internal/watch/consumer.go @@ -295,7 +295,10 @@ func (c *Consumer) handleScalingEvent(ctx context.Context, event *privatev1.Even return c.publishAndUpsert(ctx, func() error { if mapper.ResourceType() == events.ResourceTypeClusterOrder { - changed := events.ChangedComponents(existing.BillingDimensions, dims) + changed, err := events.ChangedComponents(existing.BillingDimensions, dims) + if err != nil { + return err + } if len(changed) == 0 { c.logger.V(1).Info("non-component dimension change, projection updated", "resource_id", resourceID) @@ -307,7 +310,6 @@ func (c *Consumer) handleScalingEvent(ctx context.Context, event *privatev1.Even scalingCtx = &events.StateContext{ PreviousState: stateCtx.PreviousState, WasBillable: stateCtx.WasBillable, - NewDimensions: stateCtx.NewDimensions, } } ce, ceErr := c.buildScalingEvent( @@ -358,6 +360,9 @@ func (c *Consumer) buildComponentEvent(baseCE *cloudevents.Event, eventID string if err := baseCE.DataAs(&baseData); err != nil { return ce, fmt.Errorf("reading base event data: %w", err) } + if baseData == nil { + baseData = map[string]any{} + } baseData["billing_dimensions"] = dims if err := ce.SetData(cloudevents.ApplicationJSON, baseData); err != nil { @@ -379,25 +384,7 @@ func (c *Consumer) buildScalingEvent(eventID string, mapper events.ResourceMappe } events.SetOSACExtensions(&ce, mapper.ResourceID(), mapper.ResourceType(), mapper.TenantID(), projectID) - var prevStatePtr *string - if stateCtx.PreviousState != "" { - prevStatePtr = &stateCtx.PreviousState - } - - data := map[string]any{ - "resource_id": mapper.ResourceID(), - "resource_type": mapper.ResourceType(), - "tenant_id": mapper.TenantID(), - "project_id": mapper.ProjectID(), - "catalog_item_id": mapper.CatalogItemID(), - "template_id": mapper.TemplateID(), - "previous_state": prevStatePtr, - "current_state": mapper.CurrentState(), - "transition_time": transitionTime.Format(time.RFC3339Nano), - "duration_seconds": stateCtx.DurationSeconds, - "billing_dimensions": dims, - "schema_version": "v1", - } + data := events.BuildLifecycleData(mapper, dims, stateCtx.PreviousState, stateCtx.DurationSeconds, transitionTime) if err := ce.SetData(cloudevents.ApplicationJSON, data); err != nil { return ce, fmt.Errorf("setting scaling event data: %w", err) } @@ -464,7 +451,6 @@ func (c *Consumer) buildStateContext(existing *projection.ResourceState, nowBill sc := &events.StateContext{ PreviousState: existing.CurrentState, WasBillable: existing.IsBillable, - NewDimensions: newDims, } if existing.IsBillable && existing.BillableSince != nil { diff --git a/osac-metering/metering-service/internal/watch/consumer_internal_test.go b/osac-metering/metering-service/internal/watch/consumer_internal_test.go new file mode 100644 index 000000000..b73b4352a --- /dev/null +++ b/osac-metering/metering-service/internal/watch/consumer_internal_test.go @@ -0,0 +1,39 @@ +/* +Copyright (c) 2026 Red Hat, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ + +package watch + +import ( + "testing" + + cloudevents "github.com/cloudevents/sdk-go/v2" +) + +func TestBuildComponentEventHandlesNilBaseData(t *testing.T) { + c := &Consumer{} + baseCE := cloudevents.NewEvent() + baseCE.SetID("evt-1") + baseCE.SetSource("osac-metering") + baseCE.SetType("osac.resource.started.v1") + // No SetData call: the base event carries zero-length data, so DataAs + // leaves the target map nil without returning an error. + + ce, err := c.buildComponentEvent(&baseCE, "evt-1/node-a", map[string]any{"component": "worker"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var data map[string]any + if err := ce.DataAs(&data); err != nil { + t.Fatalf("unexpected error reading component event data: %v", err) + } + if data["billing_dimensions"] == nil { + t.Errorf("expected billing_dimensions to be set on the component event even when the base event carried no data") + } +} diff --git a/osac-metering/metering-service/internal/watch/consumer_test.go b/osac-metering/metering-service/internal/watch/consumer_test.go index 86b634927..b32364ccb 100644 --- a/osac-metering/metering-service/internal/watch/consumer_test.go +++ b/osac-metering/metering-service/internal/watch/consumer_test.go @@ -1378,7 +1378,8 @@ var _ = Describe("Consumer", func() { BillableSince: &billableStart, FulfillmentVersion: 1, BillingDimensions: map[string]any{ - "cluster_template": "tmpl", + "cluster_template": "ocp-ci-small", + "release_image": "4.17.0", "components": []any{ map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, }, @@ -1432,6 +1433,7 @@ var _ = Describe("Consumer", func() { FulfillmentVersion: 1, BillingDimensions: map[string]any{ "cluster_template": "ocp-ci-small", + "release_image": "4.17.0", "components": []any{ map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, From 3a2c50baaeb061d1607e2e18afba148c7d78b041 Mon Sep 17 00:00:00 2001 From: omer-vishlitzky Date: Sun, 9 Aug 2026 18:14:48 +0300 Subject: [PATCH 18/18] fix: close proto skew, staggered-scaling duration, and node_count validation gaps from review - Bump private-api pin to v0.0.84 and regenerate: fulfillment-service's version_name->ClusterVersionReference conversion made metering's stale proto silently decode the wrong field. Read spec.GetVersion().GetName(). - Track per-component billable-since (ComponentBillableSince) instead of one cluster-wide timestamp, so staggered scaling of independent node sets no longer understates duration_seconds for whichever component didn't cause the most recent reset. Threaded through Reconciler's missed_creation/state_drift paths too, since they write projection rows directly. Folded into the initial migration (no live database yet). - Remove node_count validation from DecomposeClusterComponents entirely: fulfillment-service already rejects non-positive node set sizes at the API layer, and ClusterBillingDimensions always writes a valid typed value, so the check (and JSONB-corruption-only failure mode a stricter version of it would have added) defended against a scenario that can't occur through any real code path. Co-Authored-By: Claude Sonnet 5 --- osac-metering/metering-service/buf.gen.yaml | 2 +- .../private/v1/baremetal_instance_type.pb.go | 388 ++++++++----- .../private/v1/cluster_template_type.pb.go | 206 +++---- .../api/osac/private/v1/cluster_type.pb.go | 494 ++++++++--------- .../private/v1/cluster_version_type.pb.go | 148 +++-- .../private/v1/compute_instance_type.pb.go | 514 +++++++++--------- .../api/osac/private/v1/metadata_type.pb.go | 90 ++- .../osac/private/v1/network_class_type.pb.go | 316 +++++------ .../api/osac/private/v1/project_type.pb.go | 181 +++--- .../0_create_metering_resource_state.up.sql | 1 + .../internal/events/cluster.go | 60 +- .../internal/events/cluster_test.go | 56 +- .../internal/projection/postgres.go | 36 +- .../internal/projection/types.go | 31 +- .../internal/reconciliation/reconciler.go | 3 + .../reconciliation/reconciler_test.go | 5 +- .../internal/watch/consumer.go | 40 +- .../internal/watch/consumer_test.go | 98 +++- 18 files changed, 1503 insertions(+), 1166 deletions(-) diff --git a/osac-metering/metering-service/buf.gen.yaml b/osac-metering/metering-service/buf.gen.yaml index 7ecdfe24e..b6125e283 100644 --- a/osac-metering/metering-service/buf.gen.yaml +++ b/osac-metering/metering-service/buf.gen.yaml @@ -25,7 +25,7 @@ managed: inputs: -- module: buf.build/osac-project/private-api:v0.0.83 +- module: buf.build/osac-project/private-api:v0.0.84 plugins: diff --git a/osac-metering/metering-service/internal/api/osac/private/v1/baremetal_instance_type.pb.go b/osac-metering/metering-service/internal/api/osac/private/v1/baremetal_instance_type.pb.go index 1211fcb20..1bb654d69 100644 --- a/osac-metering/metering-service/internal/api/osac/private/v1/baremetal_instance_type.pb.go +++ b/osac-metering/metering-service/internal/api/osac/private/v1/baremetal_instance_type.pb.go @@ -567,9 +567,12 @@ type BareMetalInstanceStatus struct { // The controller sets this to match `spec.restart_trigger` once the power cycle is complete. RestartTrigger int64 `protobuf:"varint,3,opt,name=restart_trigger,json=restartTrigger,proto3" json:"restart_trigger,omitempty"` // Identifier of the hub that was selected for this bare metal instance. - Hub string `protobuf:"bytes,4,opt,name=hub,proto3" json:"hub,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Hub string `protobuf:"bytes,4,opt,name=hub,proto3" json:"hub,omitempty"` + // Runtime networking state for each network attachment. + // Populated by the operator after DHCP lease discovery and synced via the feedback controller. + NetworkAttachmentStatuses []*BareMetalNetworkAttachmentStatus `protobuf:"bytes,5,rep,name=network_attachment_statuses,json=networkAttachmentStatuses,proto3" json:"network_attachment_statuses,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *BareMetalInstanceStatus) Reset() { @@ -630,6 +633,86 @@ func (x *BareMetalInstanceStatus) GetHub() string { return "" } +func (x *BareMetalInstanceStatus) GetNetworkAttachmentStatuses() []*BareMetalNetworkAttachmentStatus { + if x != nil { + return x.NetworkAttachmentStatuses + } + return nil +} + +// Runtime networking state for a single bare metal network attachment. +type BareMetalNetworkAttachmentStatus struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Physical interface name from the spec's network attachment. + Interface string `protobuf:"bytes,1,opt,name=interface,proto3" json:"interface,omitempty"` + // Reference to the Subnet associated with this attachment. + SubnetRef string `protobuf:"bytes,2,opt,name=subnet_ref,json=subnetRef,proto3" json:"subnet_ref,omitempty"` + // IP address discovered after DHCP assignment. + IpAddress string `protobuf:"bytes,3,opt,name=ip_address,json=ipAddress,proto3" json:"ip_address,omitempty"` + // Whether this attachment is the primary (default gateway) attachment. + Primary bool `protobuf:"varint,4,opt,name=primary,proto3" json:"primary,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BareMetalNetworkAttachmentStatus) Reset() { + *x = BareMetalNetworkAttachmentStatus{} + mi := &file_osac_private_v1_baremetal_instance_type_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BareMetalNetworkAttachmentStatus) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BareMetalNetworkAttachmentStatus) ProtoMessage() {} + +func (x *BareMetalNetworkAttachmentStatus) ProtoReflect() protoreflect.Message { + mi := &file_osac_private_v1_baremetal_instance_type_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BareMetalNetworkAttachmentStatus.ProtoReflect.Descriptor instead. +func (*BareMetalNetworkAttachmentStatus) Descriptor() ([]byte, []int) { + return file_osac_private_v1_baremetal_instance_type_proto_rawDescGZIP(), []int{5} +} + +func (x *BareMetalNetworkAttachmentStatus) GetInterface() string { + if x != nil { + return x.Interface + } + return "" +} + +func (x *BareMetalNetworkAttachmentStatus) GetSubnetRef() string { + if x != nil { + return x.SubnetRef + } + return "" +} + +func (x *BareMetalNetworkAttachmentStatus) GetIpAddress() string { + if x != nil { + return x.IpAddress + } + return "" +} + +func (x *BareMetalNetworkAttachmentStatus) GetPrimary() bool { + if x != nil { + return x.Primary + } + return false +} + // Contains the details of a condition that describes the status of a bare metal instance. type BareMetalInstanceCondition struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -644,7 +727,7 @@ type BareMetalInstanceCondition struct { func (x *BareMetalInstanceCondition) Reset() { *x = BareMetalInstanceCondition{} - mi := &file_osac_private_v1_baremetal_instance_type_proto_msgTypes[5] + mi := &file_osac_private_v1_baremetal_instance_type_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -656,7 +739,7 @@ func (x *BareMetalInstanceCondition) String() string { func (*BareMetalInstanceCondition) ProtoMessage() {} func (x *BareMetalInstanceCondition) ProtoReflect() protoreflect.Message { - mi := &file_osac_private_v1_baremetal_instance_type_proto_msgTypes[5] + mi := &file_osac_private_v1_baremetal_instance_type_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -669,7 +752,7 @@ func (x *BareMetalInstanceCondition) ProtoReflect() protoreflect.Message { // Deprecated: Use BareMetalInstanceCondition.ProtoReflect.Descriptor instead. func (*BareMetalInstanceCondition) Descriptor() ([]byte, []int) { - return file_osac_private_v1_baremetal_instance_type_proto_rawDescGZIP(), []int{5} + return file_osac_private_v1_baremetal_instance_type_proto_rawDescGZIP(), []int{6} } func (x *BareMetalInstanceCondition) GetType() BareMetalInstanceConditionType { @@ -718,7 +801,7 @@ type BareMetalInstanceLocalReference struct { func (x *BareMetalInstanceLocalReference) Reset() { *x = BareMetalInstanceLocalReference{} - mi := &file_osac_private_v1_baremetal_instance_type_proto_msgTypes[6] + mi := &file_osac_private_v1_baremetal_instance_type_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -730,7 +813,7 @@ func (x *BareMetalInstanceLocalReference) String() string { func (*BareMetalInstanceLocalReference) ProtoMessage() {} func (x *BareMetalInstanceLocalReference) ProtoReflect() protoreflect.Message { - mi := &file_osac_private_v1_baremetal_instance_type_proto_msgTypes[6] + mi := &file_osac_private_v1_baremetal_instance_type_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -743,7 +826,7 @@ func (x *BareMetalInstanceLocalReference) ProtoReflect() protoreflect.Message { // Deprecated: Use BareMetalInstanceLocalReference.ProtoReflect.Descriptor instead. func (*BareMetalInstanceLocalReference) Descriptor() ([]byte, []int) { - return file_osac_private_v1_baremetal_instance_type_proto_rawDescGZIP(), []int{6} + return file_osac_private_v1_baremetal_instance_type_proto_rawDescGZIP(), []int{7} } func (x *BareMetalInstanceLocalReference) GetId() string { @@ -773,7 +856,7 @@ type BareMetalInstanceCatalogItemReference struct { func (x *BareMetalInstanceCatalogItemReference) Reset() { *x = BareMetalInstanceCatalogItemReference{} - mi := &file_osac_private_v1_baremetal_instance_type_proto_msgTypes[7] + mi := &file_osac_private_v1_baremetal_instance_type_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -785,7 +868,7 @@ func (x *BareMetalInstanceCatalogItemReference) String() string { func (*BareMetalInstanceCatalogItemReference) ProtoMessage() {} func (x *BareMetalInstanceCatalogItemReference) ProtoReflect() protoreflect.Message { - mi := &file_osac_private_v1_baremetal_instance_type_proto_msgTypes[7] + mi := &file_osac_private_v1_baremetal_instance_type_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -798,7 +881,7 @@ func (x *BareMetalInstanceCatalogItemReference) ProtoReflect() protoreflect.Mess // Deprecated: Use BareMetalInstanceCatalogItemReference.ProtoReflect.Descriptor instead. func (*BareMetalInstanceCatalogItemReference) Descriptor() ([]byte, []int) { - return file_osac_private_v1_baremetal_instance_type_proto_rawDescGZIP(), []int{7} + return file_osac_private_v1_baremetal_instance_type_proto_rawDescGZIP(), []int{8} } func (x *BareMetalInstanceCatalogItemReference) GetId() string { @@ -958,7 +1041,7 @@ var file_osac_private_v1_baremetal_instance_type_proto_rawDesc = string([]byte{ 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x64, 0x61, 0x74, 0x61, 0x42, 0x0f, 0x0a, 0x0d, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x73, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x69, 0x6d, - 0x61, 0x67, 0x65, 0x22, 0xe0, 0x01, 0x0a, 0x17, 0x42, 0x61, 0x72, 0x65, 0x4d, 0x65, 0x74, 0x61, + 0x61, 0x67, 0x65, 0x22, 0xd3, 0x02, 0x0a, 0x17, 0x42, 0x61, 0x72, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x6c, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x3d, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x27, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, @@ -972,113 +1055,130 @@ var file_osac_private_v1_baremetal_instance_type_proto_rawDesc = string([]byte{ 0x65, 0x73, 0x74, 0x61, 0x72, 0x74, 0x5f, 0x74, 0x72, 0x69, 0x67, 0x67, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0e, 0x72, 0x65, 0x73, 0x74, 0x61, 0x72, 0x74, 0x54, 0x72, 0x69, 0x67, 0x67, 0x65, 0x72, 0x12, 0x10, 0x0a, 0x03, 0x68, 0x75, 0x62, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x03, 0x68, 0x75, 0x62, 0x22, 0xbc, 0x02, 0x0a, 0x1a, 0x42, 0x61, 0x72, 0x65, 0x4d, - 0x65, 0x74, 0x61, 0x6c, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x64, - 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x43, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x0e, 0x32, 0x2f, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, - 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x61, 0x72, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x6c, 0x49, - 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, - 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x38, 0x0a, 0x06, 0x73, 0x74, - 0x61, 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x20, 0x2e, 0x6f, 0x73, 0x61, - 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, - 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, - 0x61, 0x74, 0x75, 0x73, 0x12, 0x4c, 0x0a, 0x14, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x74, 0x72, 0x61, - 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x12, - 0x6c, 0x61, 0x73, 0x74, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x69, - 0x6d, 0x65, 0x12, 0x1b, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, - 0x28, 0x09, 0x48, 0x00, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x88, 0x01, 0x01, 0x12, - 0x1d, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, - 0x48, 0x01, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x88, 0x01, 0x01, 0x42, 0x09, - 0x0a, 0x07, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x6d, 0x65, - 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x45, 0x0a, 0x1f, 0x42, 0x61, 0x72, 0x65, 0x4d, 0x65, 0x74, - 0x61, 0x6c, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x52, - 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x7d, 0x0a, 0x25, - 0x42, 0x61, 0x72, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x6c, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, - 0x65, 0x43, 0x61, 0x74, 0x61, 0x6c, 0x6f, 0x67, 0x49, 0x74, 0x65, 0x6d, 0x52, 0x65, 0x66, 0x65, - 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x70, 0x72, 0x6f, - 0x6a, 0x65, 0x63, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x70, 0x72, 0x6f, 0x6a, - 0x65, 0x63, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x68, 0x61, 0x72, 0x65, 0x64, 0x18, 0x04, 0x20, - 0x01, 0x28, 0x08, 0x52, 0x06, 0x73, 0x68, 0x61, 0x72, 0x65, 0x64, 0x2a, 0xaa, 0x01, 0x0a, 0x1c, - 0x42, 0x61, 0x72, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x6c, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, - 0x65, 0x52, 0x75, 0x6e, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x12, 0x30, 0x0a, 0x2c, + 0x09, 0x52, 0x03, 0x68, 0x75, 0x62, 0x12, 0x71, 0x0a, 0x1b, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, + 0x6b, 0x5f, 0x61, 0x74, 0x74, 0x61, 0x63, 0x68, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x74, 0x61, + 0x74, 0x75, 0x73, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x31, 0x2e, 0x6f, 0x73, + 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x61, + 0x72, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x6c, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x74, + 0x74, 0x61, 0x63, 0x68, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x19, + 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x74, 0x74, 0x61, 0x63, 0x68, 0x6d, 0x65, 0x6e, + 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x65, 0x73, 0x22, 0x98, 0x01, 0x0a, 0x20, 0x42, 0x61, + 0x72, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x6c, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x74, + 0x74, 0x61, 0x63, 0x68, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x1c, + 0x0a, 0x09, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x66, 0x61, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x09, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x66, 0x61, 0x63, 0x65, 0x12, 0x1d, 0x0a, 0x0a, + 0x73, 0x75, 0x62, 0x6e, 0x65, 0x74, 0x5f, 0x72, 0x65, 0x66, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x09, 0x73, 0x75, 0x62, 0x6e, 0x65, 0x74, 0x52, 0x65, 0x66, 0x12, 0x1d, 0x0a, 0x0a, 0x69, + 0x70, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x09, 0x69, 0x70, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x70, 0x72, + 0x69, 0x6d, 0x61, 0x72, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x70, 0x72, 0x69, + 0x6d, 0x61, 0x72, 0x79, 0x22, 0xbc, 0x02, 0x0a, 0x1a, 0x42, 0x61, 0x72, 0x65, 0x4d, 0x65, 0x74, + 0x61, 0x6c, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, + 0x69, 0x6f, 0x6e, 0x12, 0x43, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0e, 0x32, 0x2f, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, + 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x61, 0x72, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x6c, 0x49, 0x6e, 0x73, + 0x74, 0x61, 0x6e, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x79, + 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x38, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, + 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x20, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, + 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x64, 0x69, + 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, + 0x75, 0x73, 0x12, 0x4c, 0x0a, 0x14, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x74, 0x72, 0x61, 0x6e, 0x73, + 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, + 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x12, 0x6c, 0x61, + 0x73, 0x74, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x69, 0x6d, 0x65, + 0x12, 0x1b, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, + 0x48, 0x00, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, + 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, + 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x88, 0x01, 0x01, 0x42, 0x09, 0x0a, 0x07, + 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x6d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x22, 0x45, 0x0a, 0x1f, 0x42, 0x61, 0x72, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x6c, + 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x52, 0x65, 0x66, + 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x7d, 0x0a, 0x25, 0x42, 0x61, + 0x72, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x6c, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x43, + 0x61, 0x74, 0x61, 0x6c, 0x6f, 0x67, 0x49, 0x74, 0x65, 0x6d, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, + 0x6e, 0x63, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, + 0x63, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, + 0x74, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x68, 0x61, 0x72, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x06, 0x73, 0x68, 0x61, 0x72, 0x65, 0x64, 0x2a, 0xaa, 0x01, 0x0a, 0x1c, 0x42, 0x61, + 0x72, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x6c, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x52, + 0x75, 0x6e, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x12, 0x30, 0x0a, 0x2c, 0x42, 0x41, + 0x52, 0x45, 0x5f, 0x4d, 0x45, 0x54, 0x41, 0x4c, 0x5f, 0x49, 0x4e, 0x53, 0x54, 0x41, 0x4e, 0x43, + 0x45, 0x5f, 0x52, 0x55, 0x4e, 0x5f, 0x53, 0x54, 0x52, 0x41, 0x54, 0x45, 0x47, 0x59, 0x5f, 0x55, + 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x2b, 0x0a, 0x27, 0x42, 0x41, 0x52, 0x45, 0x5f, 0x4d, 0x45, 0x54, 0x41, 0x4c, 0x5f, 0x49, 0x4e, 0x53, 0x54, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x52, 0x55, 0x4e, 0x5f, 0x53, 0x54, 0x52, 0x41, 0x54, 0x45, 0x47, 0x59, - 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x2b, - 0x0a, 0x27, 0x42, 0x41, 0x52, 0x45, 0x5f, 0x4d, 0x45, 0x54, 0x41, 0x4c, 0x5f, 0x49, 0x4e, 0x53, - 0x54, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x52, 0x55, 0x4e, 0x5f, 0x53, 0x54, 0x52, 0x41, 0x54, 0x45, - 0x47, 0x59, 0x5f, 0x41, 0x4c, 0x57, 0x41, 0x59, 0x53, 0x10, 0x01, 0x12, 0x2b, 0x0a, 0x27, 0x42, - 0x41, 0x52, 0x45, 0x5f, 0x4d, 0x45, 0x54, 0x41, 0x4c, 0x5f, 0x49, 0x4e, 0x53, 0x54, 0x41, 0x4e, - 0x43, 0x45, 0x5f, 0x52, 0x55, 0x4e, 0x5f, 0x53, 0x54, 0x52, 0x41, 0x54, 0x45, 0x47, 0x59, 0x5f, - 0x48, 0x41, 0x4c, 0x54, 0x45, 0x44, 0x10, 0x02, 0x2a, 0xdb, 0x02, 0x0a, 0x16, 0x42, 0x61, 0x72, - 0x65, 0x4d, 0x65, 0x74, 0x61, 0x6c, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x53, 0x74, - 0x61, 0x74, 0x65, 0x12, 0x29, 0x0a, 0x25, 0x42, 0x41, 0x52, 0x45, 0x5f, 0x4d, 0x45, 0x54, 0x41, - 0x4c, 0x5f, 0x49, 0x4e, 0x53, 0x54, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, - 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x2a, - 0x0a, 0x26, 0x42, 0x41, 0x52, 0x45, 0x5f, 0x4d, 0x45, 0x54, 0x41, 0x4c, 0x5f, 0x49, 0x4e, 0x53, - 0x54, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x50, 0x52, 0x4f, 0x56, - 0x49, 0x53, 0x49, 0x4f, 0x4e, 0x49, 0x4e, 0x47, 0x10, 0x01, 0x12, 0x25, 0x0a, 0x21, 0x42, 0x41, - 0x52, 0x45, 0x5f, 0x4d, 0x45, 0x54, 0x41, 0x4c, 0x5f, 0x49, 0x4e, 0x53, 0x54, 0x41, 0x4e, 0x43, - 0x45, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x52, 0x55, 0x4e, 0x4e, 0x49, 0x4e, 0x47, 0x10, - 0x02, 0x12, 0x24, 0x0a, 0x20, 0x42, 0x41, 0x52, 0x45, 0x5f, 0x4d, 0x45, 0x54, 0x41, 0x4c, 0x5f, - 0x49, 0x4e, 0x53, 0x54, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x46, - 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x03, 0x12, 0x26, 0x0a, 0x22, 0x42, 0x41, 0x52, 0x45, 0x5f, - 0x4d, 0x45, 0x54, 0x41, 0x4c, 0x5f, 0x49, 0x4e, 0x53, 0x54, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x53, - 0x54, 0x41, 0x54, 0x45, 0x5f, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x49, 0x4e, 0x47, 0x10, 0x04, 0x12, - 0x26, 0x0a, 0x22, 0x42, 0x41, 0x52, 0x45, 0x5f, 0x4d, 0x45, 0x54, 0x41, 0x4c, 0x5f, 0x49, 0x4e, - 0x53, 0x54, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x53, 0x54, 0x41, - 0x52, 0x54, 0x49, 0x4e, 0x47, 0x10, 0x05, 0x12, 0x26, 0x0a, 0x22, 0x42, 0x41, 0x52, 0x45, 0x5f, - 0x4d, 0x45, 0x54, 0x41, 0x4c, 0x5f, 0x49, 0x4e, 0x53, 0x54, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x53, - 0x54, 0x41, 0x54, 0x45, 0x5f, 0x53, 0x54, 0x4f, 0x50, 0x50, 0x49, 0x4e, 0x47, 0x10, 0x06, 0x12, - 0x25, 0x0a, 0x21, 0x42, 0x41, 0x52, 0x45, 0x5f, 0x4d, 0x45, 0x54, 0x41, 0x4c, 0x5f, 0x49, 0x4e, - 0x53, 0x54, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x53, 0x54, 0x4f, - 0x50, 0x50, 0x45, 0x44, 0x10, 0x07, 0x2a, 0xa0, 0x03, 0x0a, 0x1e, 0x42, 0x61, 0x72, 0x65, 0x4d, - 0x65, 0x74, 0x61, 0x6c, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x64, - 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x12, 0x32, 0x0a, 0x2e, 0x42, 0x41, 0x52, + 0x5f, 0x41, 0x4c, 0x57, 0x41, 0x59, 0x53, 0x10, 0x01, 0x12, 0x2b, 0x0a, 0x27, 0x42, 0x41, 0x52, 0x45, 0x5f, 0x4d, 0x45, 0x54, 0x41, 0x4c, 0x5f, 0x49, 0x4e, 0x53, 0x54, 0x41, 0x4e, 0x43, 0x45, - 0x5f, 0x43, 0x4f, 0x4e, 0x44, 0x49, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, - 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x32, 0x0a, - 0x2e, 0x42, 0x41, 0x52, 0x45, 0x5f, 0x4d, 0x45, 0x54, 0x41, 0x4c, 0x5f, 0x49, 0x4e, 0x53, 0x54, - 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x43, 0x4f, 0x4e, 0x44, 0x49, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, - 0x59, 0x50, 0x45, 0x5f, 0x50, 0x52, 0x4f, 0x56, 0x49, 0x53, 0x49, 0x4f, 0x4e, 0x45, 0x44, 0x10, - 0x01, 0x12, 0x3c, 0x0a, 0x38, 0x42, 0x41, 0x52, 0x45, 0x5f, 0x4d, 0x45, 0x54, 0x41, 0x4c, 0x5f, - 0x49, 0x4e, 0x53, 0x54, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x43, 0x4f, 0x4e, 0x44, 0x49, 0x54, 0x49, - 0x4f, 0x4e, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x43, 0x4f, 0x4e, 0x46, 0x49, 0x47, 0x55, 0x52, - 0x41, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x41, 0x50, 0x50, 0x4c, 0x49, 0x45, 0x44, 0x10, 0x02, 0x12, - 0x2c, 0x0a, 0x28, 0x42, 0x41, 0x52, 0x45, 0x5f, 0x4d, 0x45, 0x54, 0x41, 0x4c, 0x5f, 0x49, 0x4e, + 0x5f, 0x52, 0x55, 0x4e, 0x5f, 0x53, 0x54, 0x52, 0x41, 0x54, 0x45, 0x47, 0x59, 0x5f, 0x48, 0x41, + 0x4c, 0x54, 0x45, 0x44, 0x10, 0x02, 0x2a, 0xdb, 0x02, 0x0a, 0x16, 0x42, 0x61, 0x72, 0x65, 0x4d, + 0x65, 0x74, 0x61, 0x6c, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x53, 0x74, 0x61, 0x74, + 0x65, 0x12, 0x29, 0x0a, 0x25, 0x42, 0x41, 0x52, 0x45, 0x5f, 0x4d, 0x45, 0x54, 0x41, 0x4c, 0x5f, + 0x49, 0x4e, 0x53, 0x54, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x55, + 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x2a, 0x0a, 0x26, + 0x42, 0x41, 0x52, 0x45, 0x5f, 0x4d, 0x45, 0x54, 0x41, 0x4c, 0x5f, 0x49, 0x4e, 0x53, 0x54, 0x41, + 0x4e, 0x43, 0x45, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x50, 0x52, 0x4f, 0x56, 0x49, 0x53, + 0x49, 0x4f, 0x4e, 0x49, 0x4e, 0x47, 0x10, 0x01, 0x12, 0x25, 0x0a, 0x21, 0x42, 0x41, 0x52, 0x45, + 0x5f, 0x4d, 0x45, 0x54, 0x41, 0x4c, 0x5f, 0x49, 0x4e, 0x53, 0x54, 0x41, 0x4e, 0x43, 0x45, 0x5f, + 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x52, 0x55, 0x4e, 0x4e, 0x49, 0x4e, 0x47, 0x10, 0x02, 0x12, + 0x24, 0x0a, 0x20, 0x42, 0x41, 0x52, 0x45, 0x5f, 0x4d, 0x45, 0x54, 0x41, 0x4c, 0x5f, 0x49, 0x4e, + 0x53, 0x54, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x46, 0x41, 0x49, + 0x4c, 0x45, 0x44, 0x10, 0x03, 0x12, 0x26, 0x0a, 0x22, 0x42, 0x41, 0x52, 0x45, 0x5f, 0x4d, 0x45, + 0x54, 0x41, 0x4c, 0x5f, 0x49, 0x4e, 0x53, 0x54, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x53, 0x54, 0x41, + 0x54, 0x45, 0x5f, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x49, 0x4e, 0x47, 0x10, 0x04, 0x12, 0x26, 0x0a, + 0x22, 0x42, 0x41, 0x52, 0x45, 0x5f, 0x4d, 0x45, 0x54, 0x41, 0x4c, 0x5f, 0x49, 0x4e, 0x53, 0x54, + 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x53, 0x54, 0x41, 0x52, 0x54, + 0x49, 0x4e, 0x47, 0x10, 0x05, 0x12, 0x26, 0x0a, 0x22, 0x42, 0x41, 0x52, 0x45, 0x5f, 0x4d, 0x45, + 0x54, 0x41, 0x4c, 0x5f, 0x49, 0x4e, 0x53, 0x54, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x53, 0x54, 0x41, + 0x54, 0x45, 0x5f, 0x53, 0x54, 0x4f, 0x50, 0x50, 0x49, 0x4e, 0x47, 0x10, 0x06, 0x12, 0x25, 0x0a, + 0x21, 0x42, 0x41, 0x52, 0x45, 0x5f, 0x4d, 0x45, 0x54, 0x41, 0x4c, 0x5f, 0x49, 0x4e, 0x53, 0x54, + 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x53, 0x54, 0x4f, 0x50, 0x50, + 0x45, 0x44, 0x10, 0x07, 0x2a, 0xa0, 0x03, 0x0a, 0x1e, 0x42, 0x61, 0x72, 0x65, 0x4d, 0x65, 0x74, + 0x61, 0x6c, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, + 0x69, 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x12, 0x32, 0x0a, 0x2e, 0x42, 0x41, 0x52, 0x45, 0x5f, + 0x4d, 0x45, 0x54, 0x41, 0x4c, 0x5f, 0x49, 0x4e, 0x53, 0x54, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x43, + 0x4f, 0x4e, 0x44, 0x49, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x55, 0x4e, + 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x32, 0x0a, 0x2e, 0x42, + 0x41, 0x52, 0x45, 0x5f, 0x4d, 0x45, 0x54, 0x41, 0x4c, 0x5f, 0x49, 0x4e, 0x53, 0x54, 0x41, 0x4e, + 0x43, 0x45, 0x5f, 0x43, 0x4f, 0x4e, 0x44, 0x49, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59, 0x50, + 0x45, 0x5f, 0x50, 0x52, 0x4f, 0x56, 0x49, 0x53, 0x49, 0x4f, 0x4e, 0x45, 0x44, 0x10, 0x01, 0x12, + 0x3c, 0x0a, 0x38, 0x42, 0x41, 0x52, 0x45, 0x5f, 0x4d, 0x45, 0x54, 0x41, 0x4c, 0x5f, 0x49, 0x4e, 0x53, 0x54, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x43, 0x4f, 0x4e, 0x44, 0x49, 0x54, 0x49, 0x4f, 0x4e, - 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x52, 0x45, 0x41, 0x44, 0x59, 0x10, 0x03, 0x12, 0x3a, 0x0a, - 0x36, 0x42, 0x41, 0x52, 0x45, 0x5f, 0x4d, 0x45, 0x54, 0x41, 0x4c, 0x5f, 0x49, 0x4e, 0x53, 0x54, + 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x43, 0x4f, 0x4e, 0x46, 0x49, 0x47, 0x55, 0x52, 0x41, 0x54, + 0x49, 0x4f, 0x4e, 0x5f, 0x41, 0x50, 0x50, 0x4c, 0x49, 0x45, 0x44, 0x10, 0x02, 0x12, 0x2c, 0x0a, + 0x28, 0x42, 0x41, 0x52, 0x45, 0x5f, 0x4d, 0x45, 0x54, 0x41, 0x4c, 0x5f, 0x49, 0x4e, 0x53, 0x54, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x43, 0x4f, 0x4e, 0x44, 0x49, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, - 0x59, 0x50, 0x45, 0x5f, 0x52, 0x45, 0x53, 0x54, 0x41, 0x52, 0x54, 0x5f, 0x49, 0x4e, 0x5f, 0x50, - 0x52, 0x4f, 0x47, 0x52, 0x45, 0x53, 0x53, 0x10, 0x04, 0x12, 0x35, 0x0a, 0x31, 0x42, 0x41, 0x52, - 0x45, 0x5f, 0x4d, 0x45, 0x54, 0x41, 0x4c, 0x5f, 0x49, 0x4e, 0x53, 0x54, 0x41, 0x4e, 0x43, 0x45, - 0x5f, 0x43, 0x4f, 0x4e, 0x44, 0x49, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, - 0x52, 0x45, 0x53, 0x54, 0x41, 0x52, 0x54, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x05, - 0x12, 0x37, 0x0a, 0x33, 0x42, 0x41, 0x52, 0x45, 0x5f, 0x4d, 0x45, 0x54, 0x41, 0x4c, 0x5f, 0x49, - 0x4e, 0x53, 0x54, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x43, 0x4f, 0x4e, 0x44, 0x49, 0x54, 0x49, 0x4f, - 0x4e, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x52, 0x45, 0x53, 0x54, 0x41, 0x52, 0x54, 0x5f, 0x52, - 0x45, 0x51, 0x55, 0x49, 0x52, 0x45, 0x44, 0x10, 0x06, 0x42, 0xdf, 0x01, 0x0a, 0x13, 0x63, 0x6f, - 0x6d, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, - 0x31, 0x42, 0x1a, 0x42, 0x61, 0x72, 0x65, 0x6d, 0x65, 0x74, 0x61, 0x6c, 0x49, 0x6e, 0x73, 0x74, - 0x61, 0x6e, 0x63, 0x65, 0x54, 0x79, 0x70, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a, - 0x4c, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6f, 0x73, 0x61, 0x63, - 0x2d, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x2f, 0x6f, 0x73, 0x61, 0x63, 0x2d, 0x6d, 0x65, - 0x74, 0x65, 0x72, 0x69, 0x6e, 0x67, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, - 0x61, 0x70, 0x69, 0x2f, 0x6f, 0x73, 0x61, 0x63, 0x2f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, - 0x2f, 0x76, 0x31, 0x3b, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x76, 0x31, 0xa2, 0x02, 0x03, - 0x4f, 0x50, 0x58, 0xaa, 0x02, 0x0f, 0x4f, 0x73, 0x61, 0x63, 0x2e, 0x50, 0x72, 0x69, 0x76, 0x61, - 0x74, 0x65, 0x2e, 0x56, 0x31, 0xca, 0x02, 0x10, 0x4f, 0x73, 0x61, 0x63, 0x5c, 0x50, 0x72, 0x69, - 0x76, 0x61, 0x74, 0x65, 0x5f, 0x5c, 0x56, 0x31, 0xe2, 0x02, 0x1c, 0x4f, 0x73, 0x61, 0x63, 0x5c, - 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, 0x5c, 0x56, 0x31, 0x5c, 0x47, 0x50, 0x42, 0x4d, - 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0xea, 0x02, 0x11, 0x4f, 0x73, 0x61, 0x63, 0x3a, 0x3a, - 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x3a, 0x3a, 0x56, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x33, + 0x59, 0x50, 0x45, 0x5f, 0x52, 0x45, 0x41, 0x44, 0x59, 0x10, 0x03, 0x12, 0x3a, 0x0a, 0x36, 0x42, + 0x41, 0x52, 0x45, 0x5f, 0x4d, 0x45, 0x54, 0x41, 0x4c, 0x5f, 0x49, 0x4e, 0x53, 0x54, 0x41, 0x4e, + 0x43, 0x45, 0x5f, 0x43, 0x4f, 0x4e, 0x44, 0x49, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59, 0x50, + 0x45, 0x5f, 0x52, 0x45, 0x53, 0x54, 0x41, 0x52, 0x54, 0x5f, 0x49, 0x4e, 0x5f, 0x50, 0x52, 0x4f, + 0x47, 0x52, 0x45, 0x53, 0x53, 0x10, 0x04, 0x12, 0x35, 0x0a, 0x31, 0x42, 0x41, 0x52, 0x45, 0x5f, + 0x4d, 0x45, 0x54, 0x41, 0x4c, 0x5f, 0x49, 0x4e, 0x53, 0x54, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x43, + 0x4f, 0x4e, 0x44, 0x49, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x52, 0x45, + 0x53, 0x54, 0x41, 0x52, 0x54, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x05, 0x12, 0x37, + 0x0a, 0x33, 0x42, 0x41, 0x52, 0x45, 0x5f, 0x4d, 0x45, 0x54, 0x41, 0x4c, 0x5f, 0x49, 0x4e, 0x53, + 0x54, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x43, 0x4f, 0x4e, 0x44, 0x49, 0x54, 0x49, 0x4f, 0x4e, 0x5f, + 0x54, 0x59, 0x50, 0x45, 0x5f, 0x52, 0x45, 0x53, 0x54, 0x41, 0x52, 0x54, 0x5f, 0x52, 0x45, 0x51, + 0x55, 0x49, 0x52, 0x45, 0x44, 0x10, 0x06, 0x42, 0xdf, 0x01, 0x0a, 0x13, 0x63, 0x6f, 0x6d, 0x2e, + 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x42, + 0x1a, 0x42, 0x61, 0x72, 0x65, 0x6d, 0x65, 0x74, 0x61, 0x6c, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, + 0x63, 0x65, 0x54, 0x79, 0x70, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x4c, 0x67, + 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6f, 0x73, 0x61, 0x63, 0x2d, 0x70, + 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x2f, 0x6f, 0x73, 0x61, 0x63, 0x2d, 0x6d, 0x65, 0x74, 0x65, + 0x72, 0x69, 0x6e, 0x67, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x61, 0x70, + 0x69, 0x2f, 0x6f, 0x73, 0x61, 0x63, 0x2f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2f, 0x76, + 0x31, 0x3b, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x76, 0x31, 0xa2, 0x02, 0x03, 0x4f, 0x50, + 0x58, 0xaa, 0x02, 0x0f, 0x4f, 0x73, 0x61, 0x63, 0x2e, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, + 0x2e, 0x56, 0x31, 0xca, 0x02, 0x10, 0x4f, 0x73, 0x61, 0x63, 0x5c, 0x50, 0x72, 0x69, 0x76, 0x61, + 0x74, 0x65, 0x5f, 0x5c, 0x56, 0x31, 0xe2, 0x02, 0x1c, 0x4f, 0x73, 0x61, 0x63, 0x5c, 0x50, 0x72, + 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, 0x5c, 0x56, 0x31, 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65, 0x74, + 0x61, 0x64, 0x61, 0x74, 0x61, 0xea, 0x02, 0x11, 0x4f, 0x73, 0x61, 0x63, 0x3a, 0x3a, 0x50, 0x72, + 0x69, 0x76, 0x61, 0x74, 0x65, 0x3a, 0x3a, 0x56, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x33, }) var ( @@ -1094,7 +1194,7 @@ func file_osac_private_v1_baremetal_instance_type_proto_rawDescGZIP() []byte { } var file_osac_private_v1_baremetal_instance_type_proto_enumTypes = make([]protoimpl.EnumInfo, 3) -var file_osac_private_v1_baremetal_instance_type_proto_msgTypes = make([]protoimpl.MessageInfo, 9) +var file_osac_private_v1_baremetal_instance_type_proto_msgTypes = make([]protoimpl.MessageInfo, 10) var file_osac_private_v1_baremetal_instance_type_proto_goTypes = []any{ (BareMetalInstanceRunStrategy)(0), // 0: osac.private.v1.BareMetalInstanceRunStrategy (BareMetalInstanceState)(0), // 1: osac.private.v1.BareMetalInstanceState @@ -1104,39 +1204,41 @@ var file_osac_private_v1_baremetal_instance_type_proto_goTypes = []any{ (*BareMetalNetworkAttachment)(nil), // 5: osac.private.v1.BareMetalNetworkAttachment (*BareMetalInstanceSpec)(nil), // 6: osac.private.v1.BareMetalInstanceSpec (*BareMetalInstanceStatus)(nil), // 7: osac.private.v1.BareMetalInstanceStatus - (*BareMetalInstanceCondition)(nil), // 8: osac.private.v1.BareMetalInstanceCondition - (*BareMetalInstanceLocalReference)(nil), // 9: osac.private.v1.BareMetalInstanceLocalReference - (*BareMetalInstanceCatalogItemReference)(nil), // 10: osac.private.v1.BareMetalInstanceCatalogItemReference - nil, // 11: osac.private.v1.BareMetalInstanceSpec.TemplateParametersEntry - (*Metadata)(nil), // 12: osac.private.v1.Metadata - (*SubnetLocalReference)(nil), // 13: osac.private.v1.SubnetLocalReference - (*SecurityGroupLocalReference)(nil), // 14: osac.private.v1.SecurityGroupLocalReference - (ConditionStatus)(0), // 15: osac.private.v1.ConditionStatus - (*timestamppb.Timestamp)(nil), // 16: google.protobuf.Timestamp - (*anypb.Any)(nil), // 17: google.protobuf.Any + (*BareMetalNetworkAttachmentStatus)(nil), // 8: osac.private.v1.BareMetalNetworkAttachmentStatus + (*BareMetalInstanceCondition)(nil), // 9: osac.private.v1.BareMetalInstanceCondition + (*BareMetalInstanceLocalReference)(nil), // 10: osac.private.v1.BareMetalInstanceLocalReference + (*BareMetalInstanceCatalogItemReference)(nil), // 11: osac.private.v1.BareMetalInstanceCatalogItemReference + nil, // 12: osac.private.v1.BareMetalInstanceSpec.TemplateParametersEntry + (*Metadata)(nil), // 13: osac.private.v1.Metadata + (*SubnetLocalReference)(nil), // 14: osac.private.v1.SubnetLocalReference + (*SecurityGroupLocalReference)(nil), // 15: osac.private.v1.SecurityGroupLocalReference + (ConditionStatus)(0), // 16: osac.private.v1.ConditionStatus + (*timestamppb.Timestamp)(nil), // 17: google.protobuf.Timestamp + (*anypb.Any)(nil), // 18: google.protobuf.Any } var file_osac_private_v1_baremetal_instance_type_proto_depIdxs = []int32{ - 12, // 0: osac.private.v1.BareMetalInstance.metadata:type_name -> osac.private.v1.Metadata + 13, // 0: osac.private.v1.BareMetalInstance.metadata:type_name -> osac.private.v1.Metadata 6, // 1: osac.private.v1.BareMetalInstance.spec:type_name -> osac.private.v1.BareMetalInstanceSpec 7, // 2: osac.private.v1.BareMetalInstance.status:type_name -> osac.private.v1.BareMetalInstanceStatus - 13, // 3: osac.private.v1.BareMetalNetworkAttachment.subnet:type_name -> osac.private.v1.SubnetLocalReference - 14, // 4: osac.private.v1.BareMetalNetworkAttachment.security_groups:type_name -> osac.private.v1.SecurityGroupLocalReference - 10, // 5: osac.private.v1.BareMetalInstanceSpec.catalog_item:type_name -> osac.private.v1.BareMetalInstanceCatalogItemReference + 14, // 3: osac.private.v1.BareMetalNetworkAttachment.subnet:type_name -> osac.private.v1.SubnetLocalReference + 15, // 4: osac.private.v1.BareMetalNetworkAttachment.security_groups:type_name -> osac.private.v1.SecurityGroupLocalReference + 11, // 5: osac.private.v1.BareMetalInstanceSpec.catalog_item:type_name -> osac.private.v1.BareMetalInstanceCatalogItemReference 0, // 6: osac.private.v1.BareMetalInstanceSpec.run_strategy:type_name -> osac.private.v1.BareMetalInstanceRunStrategy - 11, // 7: osac.private.v1.BareMetalInstanceSpec.template_parameters:type_name -> osac.private.v1.BareMetalInstanceSpec.TemplateParametersEntry + 12, // 7: osac.private.v1.BareMetalInstanceSpec.template_parameters:type_name -> osac.private.v1.BareMetalInstanceSpec.TemplateParametersEntry 3, // 8: osac.private.v1.BareMetalInstanceSpec.image:type_name -> osac.private.v1.BareMetalInstanceImage 5, // 9: osac.private.v1.BareMetalInstanceSpec.network_attachments:type_name -> osac.private.v1.BareMetalNetworkAttachment 1, // 10: osac.private.v1.BareMetalInstanceStatus.state:type_name -> osac.private.v1.BareMetalInstanceState - 8, // 11: osac.private.v1.BareMetalInstanceStatus.conditions:type_name -> osac.private.v1.BareMetalInstanceCondition - 2, // 12: osac.private.v1.BareMetalInstanceCondition.type:type_name -> osac.private.v1.BareMetalInstanceConditionType - 15, // 13: osac.private.v1.BareMetalInstanceCondition.status:type_name -> osac.private.v1.ConditionStatus - 16, // 14: osac.private.v1.BareMetalInstanceCondition.last_transition_time:type_name -> google.protobuf.Timestamp - 17, // 15: osac.private.v1.BareMetalInstanceSpec.TemplateParametersEntry.value:type_name -> google.protobuf.Any - 16, // [16:16] is the sub-list for method output_type - 16, // [16:16] is the sub-list for method input_type - 16, // [16:16] is the sub-list for extension type_name - 16, // [16:16] is the sub-list for extension extendee - 0, // [0:16] is the sub-list for field type_name + 9, // 11: osac.private.v1.BareMetalInstanceStatus.conditions:type_name -> osac.private.v1.BareMetalInstanceCondition + 8, // 12: osac.private.v1.BareMetalInstanceStatus.network_attachment_statuses:type_name -> osac.private.v1.BareMetalNetworkAttachmentStatus + 2, // 13: osac.private.v1.BareMetalInstanceCondition.type:type_name -> osac.private.v1.BareMetalInstanceConditionType + 16, // 14: osac.private.v1.BareMetalInstanceCondition.status:type_name -> osac.private.v1.ConditionStatus + 17, // 15: osac.private.v1.BareMetalInstanceCondition.last_transition_time:type_name -> google.protobuf.Timestamp + 18, // 16: osac.private.v1.BareMetalInstanceSpec.TemplateParametersEntry.value:type_name -> google.protobuf.Any + 17, // [17:17] is the sub-list for method output_type + 17, // [17:17] is the sub-list for method input_type + 17, // [17:17] is the sub-list for extension type_name + 17, // [17:17] is the sub-list for extension extendee + 0, // [0:17] is the sub-list for field type_name } func init() { file_osac_private_v1_baremetal_instance_type_proto_init() } @@ -1150,14 +1252,14 @@ func file_osac_private_v1_baremetal_instance_type_proto_init() { file_osac_private_v1_subnet_type_proto_init() file_osac_private_v1_baremetal_instance_type_proto_msgTypes[2].OneofWrappers = []any{} file_osac_private_v1_baremetal_instance_type_proto_msgTypes[3].OneofWrappers = []any{} - file_osac_private_v1_baremetal_instance_type_proto_msgTypes[5].OneofWrappers = []any{} + file_osac_private_v1_baremetal_instance_type_proto_msgTypes[6].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_osac_private_v1_baremetal_instance_type_proto_rawDesc), len(file_osac_private_v1_baremetal_instance_type_proto_rawDesc)), NumEnums: 3, - NumMessages: 9, + NumMessages: 10, NumExtensions: 0, NumServices: 0, }, diff --git a/osac-metering/metering-service/internal/api/osac/private/v1/cluster_template_type.pb.go b/osac-metering/metering-service/internal/api/osac/private/v1/cluster_template_type.pb.go index 15540bc09..4facfccc5 100644 --- a/osac-metering/metering-service/internal/api/osac/private/v1/cluster_template_type.pb.go +++ b/osac-metering/metering-service/internal/api/osac/private/v1/cluster_template_type.pb.go @@ -278,8 +278,8 @@ type ClusterTemplateSpecDefaults struct { SshPublicKey *string `protobuf:"bytes,2,opt,name=ssh_public_key,json=sshPublicKey,proto3,oneof" json:"ssh_public_key,omitempty"` // Default cluster networking configuration. Network *ClusterNetwork `protobuf:"bytes,4,opt,name=network,proto3,oneof" json:"network,omitempty"` - // Default ClusterVersion name for clusters created with this template. - VersionName *string `protobuf:"bytes,5,opt,name=version_name,json=versionName,proto3,oneof" json:"version_name,omitempty"` + // Default ClusterVersion for clusters created with this template. + Version *ClusterVersionReference `protobuf:"bytes,5,opt,name=version,proto3" json:"version,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -335,11 +335,11 @@ func (x *ClusterTemplateSpecDefaults) GetNetwork() *ClusterNetwork { return nil } -func (x *ClusterTemplateSpecDefaults) GetVersionName() string { - if x != nil && x.VersionName != nil { - return *x.VersionName +func (x *ClusterTemplateSpecDefaults) GetVersion() *ClusterVersionReference { + if x != nil { + return x.Version } - return "" + return nil } var File_osac_private_v1_cluster_template_type_proto protoreflect.FileDescriptor @@ -352,95 +352,98 @@ var file_osac_private_v1_cluster_template_type_proto_rawDesc = string([]byte{ 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x61, 0x6e, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x22, 0x6f, 0x73, 0x61, 0x63, 0x2f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x31, 0x2f, 0x63, 0x6c, 0x75, 0x73, 0x74, - 0x65, 0x72, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x23, 0x6f, - 0x73, 0x61, 0x63, 0x2f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x31, 0x2f, 0x6d, - 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x1a, 0x24, 0x6f, 0x73, 0x61, 0x63, 0x2f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, - 0x2f, 0x76, 0x31, 0x2f, 0x68, 0x6f, 0x73, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x5f, 0x74, 0x79, - 0x70, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xeb, 0x03, 0x0a, 0x0f, 0x43, 0x6c, 0x75, - 0x73, 0x74, 0x65, 0x72, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x12, 0x0e, 0x0a, 0x02, - 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x35, 0x0a, 0x08, - 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, - 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, - 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, - 0x61, 0x74, 0x61, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x69, 0x74, 0x6c, 0x65, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x05, 0x74, 0x69, 0x74, 0x6c, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, - 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, - 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x53, 0x0a, 0x0a, 0x70, - 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, - 0x33, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, + 0x65, 0x72, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x2a, 0x6f, + 0x73, 0x61, 0x63, 0x2f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x31, 0x2f, 0x63, + 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, + 0x79, 0x70, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x23, 0x6f, 0x73, 0x61, 0x63, 0x2f, + 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x31, 0x2f, 0x6d, 0x65, 0x74, 0x61, 0x64, + 0x61, 0x74, 0x61, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x24, + 0x6f, 0x73, 0x61, 0x63, 0x2f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x31, 0x2f, + 0x68, 0x6f, 0x73, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xeb, 0x03, 0x0a, 0x0f, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, + 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x35, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, + 0x64, 0x61, 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6f, 0x73, 0x61, + 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x65, 0x74, + 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, + 0x14, 0x0a, 0x05, 0x74, 0x69, 0x74, 0x6c, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, + 0x74, 0x69, 0x74, 0x6c, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, + 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, + 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x53, 0x0a, 0x0a, 0x70, 0x61, 0x72, 0x61, 0x6d, + 0x65, 0x74, 0x65, 0x72, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x33, 0x2e, 0x6f, 0x73, + 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, + 0x75, 0x73, 0x74, 0x65, 0x72, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x50, 0x61, 0x72, + 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, + 0x52, 0x0a, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x12, 0x4b, 0x0a, 0x09, + 0x6e, 0x6f, 0x64, 0x65, 0x5f, 0x73, 0x65, 0x74, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x2e, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, - 0x65, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, - 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x0a, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, - 0x12, 0x4b, 0x0a, 0x09, 0x6e, 0x6f, 0x64, 0x65, 0x5f, 0x73, 0x65, 0x74, 0x73, 0x18, 0x06, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x2e, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, - 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x54, 0x65, 0x6d, - 0x70, 0x6c, 0x61, 0x74, 0x65, 0x2e, 0x4e, 0x6f, 0x64, 0x65, 0x53, 0x65, 0x74, 0x73, 0x45, 0x6e, - 0x74, 0x72, 0x79, 0x52, 0x08, 0x6e, 0x6f, 0x64, 0x65, 0x53, 0x65, 0x74, 0x73, 0x12, 0x51, 0x0a, - 0x0d, 0x73, 0x70, 0x65, 0x63, 0x5f, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x73, 0x18, 0x07, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2c, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, - 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x54, 0x65, - 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x53, 0x70, 0x65, 0x63, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, - 0x74, 0x73, 0x52, 0x0c, 0x73, 0x70, 0x65, 0x63, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x73, - 0x1a, 0x64, 0x0a, 0x0d, 0x4e, 0x6f, 0x64, 0x65, 0x53, 0x65, 0x74, 0x73, 0x45, 0x6e, 0x74, 0x72, - 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, - 0x6b, 0x65, 0x79, 0x12, 0x3d, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, - 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x54, 0x65, 0x6d, 0x70, - 0x6c, 0x61, 0x74, 0x65, 0x4e, 0x6f, 0x64, 0x65, 0x53, 0x65, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, - 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xd0, 0x01, 0x0a, 0x22, 0x43, 0x6c, 0x75, 0x73, 0x74, - 0x65, 0x72, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, - 0x74, 0x65, 0x72, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, - 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, - 0x65, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x69, 0x74, 0x6c, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x05, 0x74, 0x69, 0x74, 0x6c, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, - 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, - 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x71, - 0x75, 0x69, 0x72, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x72, 0x65, 0x71, - 0x75, 0x69, 0x72, 0x65, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x05, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x2e, 0x0a, 0x07, 0x64, 0x65, 0x66, - 0x61, 0x75, 0x6c, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, - 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, - 0x52, 0x07, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x22, 0x6d, 0x0a, 0x16, 0x43, 0x6c, 0x75, - 0x73, 0x74, 0x65, 0x72, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x4e, 0x6f, 0x64, 0x65, - 0x53, 0x65, 0x74, 0x12, 0x3f, 0x0a, 0x09, 0x68, 0x6f, 0x73, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, - 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x54, 0x79, 0x70, - 0x65, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x08, 0x68, 0x6f, 0x73, 0x74, - 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x05, 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x22, 0x96, 0x02, 0x0a, 0x1b, 0x43, 0x6c, 0x75, - 0x73, 0x74, 0x65, 0x72, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x53, 0x70, 0x65, 0x63, - 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x73, 0x12, 0x24, 0x0a, 0x0b, 0x70, 0x75, 0x6c, 0x6c, - 0x5f, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, - 0x0a, 0x70, 0x75, 0x6c, 0x6c, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x88, 0x01, 0x01, 0x12, 0x29, - 0x0a, 0x0e, 0x73, 0x73, 0x68, 0x5f, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x0c, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, - 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x88, 0x01, 0x01, 0x12, 0x3e, 0x0a, 0x07, 0x6e, 0x65, 0x74, - 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6f, 0x73, 0x61, - 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, - 0x73, 0x74, 0x65, 0x72, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x48, 0x02, 0x52, 0x07, 0x6e, - 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x88, 0x01, 0x01, 0x12, 0x26, 0x0a, 0x0c, 0x76, 0x65, 0x72, - 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, - 0x03, 0x52, 0x0b, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x4e, 0x61, 0x6d, 0x65, 0x88, 0x01, - 0x01, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x70, 0x75, 0x6c, 0x6c, 0x5f, 0x73, 0x65, 0x63, 0x72, 0x65, - 0x74, 0x42, 0x11, 0x0a, 0x0f, 0x5f, 0x73, 0x73, 0x68, 0x5f, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, - 0x5f, 0x6b, 0x65, 0x79, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, - 0x42, 0x0f, 0x0a, 0x0d, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x6e, 0x61, 0x6d, - 0x65, 0x42, 0xdd, 0x01, 0x0a, 0x13, 0x63, 0x6f, 0x6d, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, - 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x42, 0x18, 0x43, 0x6c, 0x75, 0x73, 0x74, - 0x65, 0x72, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x54, 0x79, 0x70, 0x65, 0x50, 0x72, - 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x4c, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, - 0x6d, 0x2f, 0x6f, 0x73, 0x61, 0x63, 0x2d, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x2f, 0x6f, - 0x73, 0x61, 0x63, 0x2d, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x69, 0x6e, 0x67, 0x2f, 0x69, 0x6e, 0x74, - 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x6f, 0x73, 0x61, 0x63, 0x2f, 0x70, - 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x31, 0x3b, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, - 0x65, 0x76, 0x31, 0xa2, 0x02, 0x03, 0x4f, 0x50, 0x58, 0xaa, 0x02, 0x0f, 0x4f, 0x73, 0x61, 0x63, - 0x2e, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x56, 0x31, 0xca, 0x02, 0x10, 0x4f, 0x73, - 0x61, 0x63, 0x5c, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, 0x5c, 0x56, 0x31, 0xe2, 0x02, - 0x1c, 0x4f, 0x73, 0x61, 0x63, 0x5c, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, 0x5c, 0x56, - 0x31, 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0xea, 0x02, 0x11, - 0x4f, 0x73, 0x61, 0x63, 0x3a, 0x3a, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x3a, 0x3a, 0x56, - 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x65, 0x2e, 0x4e, 0x6f, 0x64, 0x65, 0x53, 0x65, 0x74, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, + 0x08, 0x6e, 0x6f, 0x64, 0x65, 0x53, 0x65, 0x74, 0x73, 0x12, 0x51, 0x0a, 0x0d, 0x73, 0x70, 0x65, + 0x63, 0x5f, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x2c, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, + 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, + 0x74, 0x65, 0x53, 0x70, 0x65, 0x63, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x73, 0x52, 0x0c, + 0x73, 0x70, 0x65, 0x63, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x73, 0x1a, 0x64, 0x0a, 0x0d, + 0x4e, 0x6f, 0x64, 0x65, 0x53, 0x65, 0x74, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, + 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, + 0x3d, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, + 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, + 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, + 0x4e, 0x6f, 0x64, 0x65, 0x53, 0x65, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, + 0x38, 0x01, 0x22, 0xd0, 0x01, 0x0a, 0x22, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x54, 0x65, + 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x44, + 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, + 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x14, 0x0a, + 0x05, 0x74, 0x69, 0x74, 0x6c, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x69, + 0x74, 0x6c, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, + 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, + 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, + 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, + 0x64, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x2e, 0x0a, 0x07, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, + 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x07, 0x64, 0x65, + 0x66, 0x61, 0x75, 0x6c, 0x74, 0x22, 0x6d, 0x0a, 0x16, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, + 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x4e, 0x6f, 0x64, 0x65, 0x53, 0x65, 0x74, 0x12, + 0x3f, 0x0a, 0x09, 0x68, 0x6f, 0x73, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, + 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x54, 0x79, 0x70, 0x65, 0x52, 0x65, 0x66, + 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x54, 0x79, 0x70, 0x65, + 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x04, + 0x73, 0x69, 0x7a, 0x65, 0x22, 0xa1, 0x02, 0x0a, 0x1b, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, + 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x53, 0x70, 0x65, 0x63, 0x44, 0x65, 0x66, 0x61, + 0x75, 0x6c, 0x74, 0x73, 0x12, 0x24, 0x0a, 0x0b, 0x70, 0x75, 0x6c, 0x6c, 0x5f, 0x73, 0x65, 0x63, + 0x72, 0x65, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0a, 0x70, 0x75, 0x6c, + 0x6c, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x88, 0x01, 0x01, 0x12, 0x29, 0x0a, 0x0e, 0x73, 0x73, + 0x68, 0x5f, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x48, 0x01, 0x52, 0x0c, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, + 0x65, 0x79, 0x88, 0x01, 0x01, 0x12, 0x3e, 0x0a, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, + 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, + 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x48, 0x02, 0x52, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, + 0x72, 0x6b, 0x88, 0x01, 0x01, 0x12, 0x42, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, + 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, + 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, + 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, + 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x70, 0x75, + 0x6c, 0x6c, 0x5f, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x42, 0x11, 0x0a, 0x0f, 0x5f, 0x73, 0x73, + 0x68, 0x5f, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x42, 0x0a, 0x0a, 0x08, + 0x5f, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x42, 0xdd, 0x01, 0x0a, 0x13, 0x63, 0x6f, 0x6d, + 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, + 0x42, 0x18, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, + 0x65, 0x54, 0x79, 0x70, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x4c, 0x67, 0x69, + 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6f, 0x73, 0x61, 0x63, 0x2d, 0x70, 0x72, + 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x2f, 0x6f, 0x73, 0x61, 0x63, 0x2d, 0x6d, 0x65, 0x74, 0x65, 0x72, + 0x69, 0x6e, 0x67, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x61, 0x70, 0x69, + 0x2f, 0x6f, 0x73, 0x61, 0x63, 0x2f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x31, + 0x3b, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x76, 0x31, 0xa2, 0x02, 0x03, 0x4f, 0x50, 0x58, + 0xaa, 0x02, 0x0f, 0x4f, 0x73, 0x61, 0x63, 0x2e, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, + 0x56, 0x31, 0xca, 0x02, 0x10, 0x4f, 0x73, 0x61, 0x63, 0x5c, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, + 0x65, 0x5f, 0x5c, 0x56, 0x31, 0xe2, 0x02, 0x1c, 0x4f, 0x73, 0x61, 0x63, 0x5c, 0x50, 0x72, 0x69, + 0x76, 0x61, 0x74, 0x65, 0x5f, 0x5c, 0x56, 0x31, 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65, 0x74, 0x61, + 0x64, 0x61, 0x74, 0x61, 0xea, 0x02, 0x11, 0x4f, 0x73, 0x61, 0x63, 0x3a, 0x3a, 0x50, 0x72, 0x69, + 0x76, 0x61, 0x74, 0x65, 0x3a, 0x3a, 0x56, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, }) var ( @@ -466,6 +469,7 @@ var file_osac_private_v1_cluster_template_type_proto_goTypes = []any{ (*anypb.Any)(nil), // 6: google.protobuf.Any (*HostTypeReference)(nil), // 7: osac.private.v1.HostTypeReference (*ClusterNetwork)(nil), // 8: osac.private.v1.ClusterNetwork + (*ClusterVersionReference)(nil), // 9: osac.private.v1.ClusterVersionReference } var file_osac_private_v1_cluster_template_type_proto_depIdxs = []int32{ 5, // 0: osac.private.v1.ClusterTemplate.metadata:type_name -> osac.private.v1.Metadata @@ -475,12 +479,13 @@ var file_osac_private_v1_cluster_template_type_proto_depIdxs = []int32{ 6, // 4: osac.private.v1.ClusterTemplateParameterDefinition.default:type_name -> google.protobuf.Any 7, // 5: osac.private.v1.ClusterTemplateNodeSet.host_type:type_name -> osac.private.v1.HostTypeReference 8, // 6: osac.private.v1.ClusterTemplateSpecDefaults.network:type_name -> osac.private.v1.ClusterNetwork - 2, // 7: osac.private.v1.ClusterTemplate.NodeSetsEntry.value:type_name -> osac.private.v1.ClusterTemplateNodeSet - 8, // [8:8] is the sub-list for method output_type - 8, // [8:8] is the sub-list for method input_type - 8, // [8:8] is the sub-list for extension type_name - 8, // [8:8] is the sub-list for extension extendee - 0, // [0:8] is the sub-list for field type_name + 9, // 7: osac.private.v1.ClusterTemplateSpecDefaults.version:type_name -> osac.private.v1.ClusterVersionReference + 2, // 8: osac.private.v1.ClusterTemplate.NodeSetsEntry.value:type_name -> osac.private.v1.ClusterTemplateNodeSet + 9, // [9:9] is the sub-list for method output_type + 9, // [9:9] is the sub-list for method input_type + 9, // [9:9] is the sub-list for extension type_name + 9, // [9:9] is the sub-list for extension extendee + 0, // [0:9] is the sub-list for field type_name } func init() { file_osac_private_v1_cluster_template_type_proto_init() } @@ -489,6 +494,7 @@ func file_osac_private_v1_cluster_template_type_proto_init() { return } file_osac_private_v1_cluster_type_proto_init() + file_osac_private_v1_cluster_version_type_proto_init() file_osac_private_v1_metadata_type_proto_init() file_osac_private_v1_host_type_type_proto_init() file_osac_private_v1_cluster_template_type_proto_msgTypes[3].OneofWrappers = []any{} diff --git a/osac-metering/metering-service/internal/api/osac/private/v1/cluster_type.pb.go b/osac-metering/metering-service/internal/api/osac/private/v1/cluster_type.pb.go index 684704fc8..74a5eb045 100644 --- a/osac-metering/metering-service/internal/api/osac/private/v1/cluster_type.pb.go +++ b/osac-metering/metering-service/internal/api/osac/private/v1/cluster_type.pb.go @@ -227,7 +227,7 @@ type ClusterSpec struct { NodeSets map[string]*ClusterNodeSet `protobuf:"bytes,3,rep,name=node_sets,json=nodeSets,proto3" json:"node_sets,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` PullSecret *string `protobuf:"bytes,4,opt,name=pull_secret,json=pullSecret,proto3,oneof" json:"pull_secret,omitempty"` SshPublicKey *string `protobuf:"bytes,5,opt,name=ssh_public_key,json=sshPublicKey,proto3,oneof" json:"ssh_public_key,omitempty"` - VersionName *string `protobuf:"bytes,6,opt,name=version_name,json=versionName,proto3,oneof" json:"version_name,omitempty"` + Version *ClusterVersionReference `protobuf:"bytes,6,opt,name=version,proto3" json:"version,omitempty"` Network *ClusterNetwork `protobuf:"bytes,7,opt,name=network,proto3,oneof" json:"network,omitempty"` // Reference to a cluster catalog item. Mutually exclusive with template during the migration period. // When set, the server fetches the catalog item and applies its field definitions. @@ -311,11 +311,11 @@ func (x *ClusterSpec) GetSshPublicKey() string { return "" } -func (x *ClusterSpec) GetVersionName() string { - if x != nil && x.VersionName != nil { - return *x.VersionName +func (x *ClusterSpec) GetVersion() *ClusterVersionReference { + if x != nil { + return x.Version } - return "" + return nil } func (x *ClusterSpec) GetNetwork() *ClusterNetwork { @@ -913,222 +913,225 @@ var file_osac_private_v1_cluster_type_proto_rawDesc = string([]byte{ 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x61, 0x6e, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x1a, 0x2b, 0x6f, 0x73, 0x61, 0x63, 0x2f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2f, - 0x76, 0x31, 0x2f, 0x63, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x73, 0x74, 0x61, - 0x74, 0x75, 0x73, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x23, - 0x6f, 0x73, 0x61, 0x63, 0x2f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x31, 0x2f, - 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x2e, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x1a, 0x24, 0x6f, 0x73, 0x61, 0x63, 0x2f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, - 0x65, 0x2f, 0x76, 0x31, 0x2f, 0x68, 0x6f, 0x73, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x5f, 0x74, - 0x79, 0x70, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x29, 0x6f, 0x73, 0x61, 0x63, 0x2f, - 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x31, 0x2f, 0x73, 0x65, 0x63, 0x75, 0x72, - 0x69, 0x74, 0x79, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x2e, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x21, 0x6f, 0x73, 0x61, 0x63, 0x2f, 0x70, 0x72, 0x69, 0x76, 0x61, - 0x74, 0x65, 0x2f, 0x76, 0x31, 0x2f, 0x73, 0x75, 0x62, 0x6e, 0x65, 0x74, 0x5f, 0x74, 0x79, 0x70, - 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xba, 0x01, 0x0a, 0x07, 0x43, 0x6c, 0x75, 0x73, - 0x74, 0x65, 0x72, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x02, 0x69, 0x64, 0x12, 0x35, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, - 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, - 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x30, 0x0a, 0x04, 0x73, 0x70, - 0x65, 0x63, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, + 0x6f, 0x1a, 0x2a, 0x6f, 0x73, 0x61, 0x63, 0x2f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2f, + 0x76, 0x31, 0x2f, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, + 0x6f, 0x6e, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x2b, 0x6f, + 0x73, 0x61, 0x63, 0x2f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x31, 0x2f, 0x63, + 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x5f, + 0x74, 0x79, 0x70, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x23, 0x6f, 0x73, 0x61, 0x63, + 0x2f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x31, 0x2f, 0x6d, 0x65, 0x74, 0x61, + 0x64, 0x61, 0x74, 0x61, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, + 0x24, 0x6f, 0x73, 0x61, 0x63, 0x2f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x31, + 0x2f, 0x68, 0x6f, 0x73, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x29, 0x6f, 0x73, 0x61, 0x63, 0x2f, 0x70, 0x72, 0x69, 0x76, + 0x61, 0x74, 0x65, 0x2f, 0x76, 0x31, 0x2f, 0x73, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x5f, + 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x1a, 0x21, 0x6f, 0x73, 0x61, 0x63, 0x2f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2f, 0x76, + 0x31, 0x2f, 0x73, 0x75, 0x62, 0x6e, 0x65, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x22, 0xba, 0x01, 0x0a, 0x07, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x12, + 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, + 0x35, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x19, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, + 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, + 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x30, 0x0a, 0x04, 0x73, 0x70, 0x65, 0x63, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, + 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x53, 0x70, + 0x65, 0x63, 0x52, 0x04, 0x73, 0x70, 0x65, 0x63, 0x12, 0x36, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, + 0x75, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74, - 0x65, 0x72, 0x53, 0x70, 0x65, 0x63, 0x52, 0x04, 0x73, 0x70, 0x65, 0x63, 0x12, 0x36, 0x0a, 0x06, - 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6f, - 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, - 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, - 0x61, 0x74, 0x75, 0x73, 0x22, 0xe3, 0x06, 0x0a, 0x0b, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, - 0x53, 0x70, 0x65, 0x63, 0x12, 0x45, 0x0a, 0x08, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, - 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, - 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, - 0x65, 0x52, 0x08, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x12, 0x65, 0x0a, 0x13, 0x74, - 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x5f, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, - 0x72, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x34, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, - 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74, - 0x65, 0x72, 0x53, 0x70, 0x65, 0x63, 0x2e, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x50, - 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x12, - 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, - 0x72, 0x73, 0x12, 0x47, 0x0a, 0x09, 0x6e, 0x6f, 0x64, 0x65, 0x5f, 0x73, 0x65, 0x74, 0x73, 0x18, - 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2a, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, - 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x53, - 0x70, 0x65, 0x63, 0x2e, 0x4e, 0x6f, 0x64, 0x65, 0x53, 0x65, 0x74, 0x73, 0x45, 0x6e, 0x74, 0x72, - 0x79, 0x52, 0x08, 0x6e, 0x6f, 0x64, 0x65, 0x53, 0x65, 0x74, 0x73, 0x12, 0x24, 0x0a, 0x0b, 0x70, - 0x75, 0x6c, 0x6c, 0x5f, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, - 0x48, 0x00, 0x52, 0x0a, 0x70, 0x75, 0x6c, 0x6c, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x88, 0x01, - 0x01, 0x12, 0x29, 0x0a, 0x0e, 0x73, 0x73, 0x68, 0x5f, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, - 0x6b, 0x65, 0x79, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x0c, 0x73, 0x73, 0x68, - 0x50, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x88, 0x01, 0x01, 0x12, 0x26, 0x0a, 0x0c, - 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x06, 0x20, 0x01, - 0x28, 0x09, 0x48, 0x02, 0x52, 0x0b, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x4e, 0x61, 0x6d, - 0x65, 0x88, 0x01, 0x01, 0x12, 0x3e, 0x0a, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, - 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, - 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x4e, - 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x48, 0x03, 0x52, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, - 0x6b, 0x88, 0x01, 0x01, 0x12, 0x4f, 0x0a, 0x0c, 0x63, 0x61, 0x74, 0x61, 0x6c, 0x6f, 0x67, 0x5f, - 0x69, 0x74, 0x65, 0x6d, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2c, 0x2e, 0x6f, 0x73, 0x61, - 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, - 0x73, 0x74, 0x65, 0x72, 0x43, 0x61, 0x74, 0x61, 0x6c, 0x6f, 0x67, 0x49, 0x74, 0x65, 0x6d, 0x52, - 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x0b, 0x63, 0x61, 0x74, 0x61, 0x6c, 0x6f, - 0x67, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x58, 0x0a, 0x12, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, - 0x5f, 0x61, 0x74, 0x74, 0x61, 0x63, 0x68, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x29, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, - 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x4e, 0x65, 0x74, 0x77, 0x6f, - 0x72, 0x6b, 0x41, 0x74, 0x74, 0x61, 0x63, 0x68, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x11, 0x6e, 0x65, - 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x74, 0x74, 0x61, 0x63, 0x68, 0x6d, 0x65, 0x6e, 0x74, 0x1a, - 0x5b, 0x0a, 0x17, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x50, 0x61, 0x72, 0x61, 0x6d, - 0x65, 0x74, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, - 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2a, 0x0a, 0x05, - 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, - 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, - 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x5c, 0x0a, 0x0d, - 0x4e, 0x6f, 0x64, 0x65, 0x53, 0x65, 0x74, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, - 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, - 0x35, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, - 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, - 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x4e, 0x6f, 0x64, 0x65, 0x53, 0x65, 0x74, 0x52, - 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x70, - 0x75, 0x6c, 0x6c, 0x5f, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x42, 0x11, 0x0a, 0x0f, 0x5f, 0x73, - 0x73, 0x68, 0x5f, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x42, 0x0f, 0x0a, - 0x0d, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x42, 0x0a, - 0x0a, 0x08, 0x5f, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x22, 0x76, 0x0a, 0x0e, 0x43, 0x6c, - 0x75, 0x73, 0x74, 0x65, 0x72, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x1e, 0x0a, 0x08, - 0x70, 0x6f, 0x64, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, - 0x52, 0x07, 0x70, 0x6f, 0x64, 0x43, 0x69, 0x64, 0x72, 0x88, 0x01, 0x01, 0x12, 0x26, 0x0a, 0x0c, - 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x09, 0x48, 0x01, 0x52, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x69, 0x64, - 0x72, 0x88, 0x01, 0x01, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x70, 0x6f, 0x64, 0x5f, 0x63, 0x69, 0x64, - 0x72, 0x42, 0x0f, 0x0a, 0x0d, 0x5f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x63, 0x69, - 0x64, 0x72, 0x22, 0xb9, 0x04, 0x0a, 0x0d, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x53, 0x74, - 0x61, 0x74, 0x75, 0x73, 0x12, 0x33, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x0e, 0x32, 0x1d, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, - 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x53, 0x74, 0x61, - 0x74, 0x65, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x41, 0x0a, 0x0a, 0x63, 0x6f, 0x6e, - 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x21, 0x2e, + 0x65, 0x72, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, + 0x22, 0xee, 0x06, 0x0a, 0x0b, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x53, 0x70, 0x65, 0x63, + 0x12, 0x45, 0x0a, 0x08, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, + 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x54, 0x65, 0x6d, 0x70, + 0x6c, 0x61, 0x74, 0x65, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x08, 0x74, + 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x12, 0x65, 0x0a, 0x13, 0x74, 0x65, 0x6d, 0x70, 0x6c, + 0x61, 0x74, 0x65, 0x5f, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x18, 0x02, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x34, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, + 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x53, 0x70, + 0x65, 0x63, 0x2e, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x50, 0x61, 0x72, 0x61, 0x6d, + 0x65, 0x74, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x12, 0x74, 0x65, 0x6d, 0x70, + 0x6c, 0x61, 0x74, 0x65, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x12, 0x47, + 0x0a, 0x09, 0x6e, 0x6f, 0x64, 0x65, 0x5f, 0x73, 0x65, 0x74, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x2a, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, + 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x53, 0x70, 0x65, 0x63, 0x2e, + 0x4e, 0x6f, 0x64, 0x65, 0x53, 0x65, 0x74, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6e, + 0x6f, 0x64, 0x65, 0x53, 0x65, 0x74, 0x73, 0x12, 0x24, 0x0a, 0x0b, 0x70, 0x75, 0x6c, 0x6c, 0x5f, + 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0a, + 0x70, 0x75, 0x6c, 0x6c, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x88, 0x01, 0x01, 0x12, 0x29, 0x0a, + 0x0e, 0x73, 0x73, 0x68, 0x5f, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x18, + 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x0c, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x6c, + 0x69, 0x63, 0x4b, 0x65, 0x79, 0x88, 0x01, 0x01, 0x12, 0x42, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, + 0x69, 0x6f, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x6f, 0x73, 0x61, 0x63, + 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73, + 0x74, 0x65, 0x72, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, + 0x6e, 0x63, 0x65, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x3e, 0x0a, 0x07, + 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, - 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, - 0x52, 0x0a, 0x63, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x17, 0x0a, 0x07, - 0x61, 0x70, 0x69, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x61, - 0x70, 0x69, 0x55, 0x72, 0x6c, 0x12, 0x1f, 0x0a, 0x0b, 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, - 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x63, 0x6f, 0x6e, 0x73, - 0x6f, 0x6c, 0x65, 0x55, 0x72, 0x6c, 0x12, 0x49, 0x0a, 0x09, 0x6e, 0x6f, 0x64, 0x65, 0x5f, 0x73, - 0x65, 0x74, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2c, 0x2e, 0x6f, 0x73, 0x61, 0x63, + 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x48, 0x02, + 0x52, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x88, 0x01, 0x01, 0x12, 0x4f, 0x0a, 0x0c, + 0x63, 0x61, 0x74, 0x61, 0x6c, 0x6f, 0x67, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x18, 0x08, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x2c, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, + 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x43, 0x61, 0x74, 0x61, + 0x6c, 0x6f, 0x67, 0x49, 0x74, 0x65, 0x6d, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, + 0x52, 0x0b, 0x63, 0x61, 0x74, 0x61, 0x6c, 0x6f, 0x67, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x58, 0x0a, + 0x12, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x5f, 0x61, 0x74, 0x74, 0x61, 0x63, 0x68, 0x6d, + 0x65, 0x6e, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73, - 0x74, 0x65, 0x72, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x2e, 0x4e, 0x6f, 0x64, 0x65, 0x53, 0x65, - 0x74, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6e, 0x6f, 0x64, 0x65, 0x53, 0x65, 0x74, - 0x73, 0x12, 0x10, 0x0a, 0x03, 0x68, 0x75, 0x62, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, - 0x68, 0x75, 0x62, 0x12, 0x21, 0x0a, 0x0c, 0x61, 0x70, 0x69, 0x5f, 0x65, 0x6e, 0x64, 0x70, 0x6f, - 0x69, 0x6e, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x61, 0x70, 0x69, 0x45, 0x6e, - 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x29, 0x0a, 0x10, 0x69, 0x6e, 0x67, 0x72, 0x65, 0x73, - 0x73, 0x5f, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x0f, 0x69, 0x6e, 0x67, 0x72, 0x65, 0x73, 0x73, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, - 0x74, 0x12, 0x53, 0x0a, 0x15, 0x73, 0x74, 0x61, 0x74, 0x65, 0x5f, 0x74, 0x72, 0x61, 0x6e, 0x73, - 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, - 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x48, 0x00, 0x52, 0x13, - 0x73, 0x74, 0x61, 0x74, 0x65, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x54, - 0x69, 0x6d, 0x65, 0x88, 0x01, 0x01, 0x1a, 0x5c, 0x0a, 0x0d, 0x4e, 0x6f, 0x64, 0x65, 0x53, 0x65, - 0x74, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x35, 0x0a, 0x05, 0x76, 0x61, 0x6c, - 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, + 0x74, 0x65, 0x72, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x74, 0x74, 0x61, 0x63, 0x68, + 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x11, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x74, 0x74, + 0x61, 0x63, 0x68, 0x6d, 0x65, 0x6e, 0x74, 0x1a, 0x5b, 0x0a, 0x17, 0x54, 0x65, 0x6d, 0x70, 0x6c, + 0x61, 0x74, 0x65, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, + 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2a, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, + 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x5c, 0x0a, 0x0d, 0x4e, 0x6f, 0x64, 0x65, 0x53, 0x65, 0x74, 0x73, + 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x35, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, + 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, + 0x4e, 0x6f, 0x64, 0x65, 0x53, 0x65, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, + 0x38, 0x01, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x70, 0x75, 0x6c, 0x6c, 0x5f, 0x73, 0x65, 0x63, 0x72, + 0x65, 0x74, 0x42, 0x11, 0x0a, 0x0f, 0x5f, 0x73, 0x73, 0x68, 0x5f, 0x70, 0x75, 0x62, 0x6c, 0x69, + 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, + 0x6b, 0x22, 0x76, 0x0a, 0x0e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x4e, 0x65, 0x74, 0x77, + 0x6f, 0x72, 0x6b, 0x12, 0x1e, 0x0a, 0x08, 0x70, 0x6f, 0x64, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x07, 0x70, 0x6f, 0x64, 0x43, 0x69, 0x64, 0x72, + 0x88, 0x01, 0x01, 0x12, 0x26, 0x0a, 0x0c, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x63, + 0x69, 0x64, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x0b, 0x73, 0x65, 0x72, + 0x76, 0x69, 0x63, 0x65, 0x43, 0x69, 0x64, 0x72, 0x88, 0x01, 0x01, 0x42, 0x0b, 0x0a, 0x09, 0x5f, + 0x70, 0x6f, 0x64, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x42, 0x0f, 0x0a, 0x0d, 0x5f, 0x73, 0x65, 0x72, + 0x76, 0x69, 0x63, 0x65, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x22, 0xb9, 0x04, 0x0a, 0x0d, 0x43, 0x6c, + 0x75, 0x73, 0x74, 0x65, 0x72, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x33, 0x0a, 0x05, 0x73, + 0x74, 0x61, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1d, 0x2e, 0x6f, 0x73, 0x61, + 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, + 0x73, 0x74, 0x65, 0x72, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, + 0x12, 0x41, 0x0a, 0x0a, 0x63, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x02, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, + 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x43, 0x6f, + 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x0a, 0x63, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, + 0x6f, 0x6e, 0x73, 0x12, 0x17, 0x0a, 0x07, 0x61, 0x70, 0x69, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x61, 0x70, 0x69, 0x55, 0x72, 0x6c, 0x12, 0x1f, 0x0a, 0x0b, + 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0a, 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x55, 0x72, 0x6c, 0x12, 0x49, 0x0a, + 0x09, 0x6e, 0x6f, 0x64, 0x65, 0x5f, 0x73, 0x65, 0x74, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x2c, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, + 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, + 0x2e, 0x4e, 0x6f, 0x64, 0x65, 0x53, 0x65, 0x74, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, + 0x6e, 0x6f, 0x64, 0x65, 0x53, 0x65, 0x74, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x68, 0x75, 0x62, 0x18, + 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x68, 0x75, 0x62, 0x12, 0x21, 0x0a, 0x0c, 0x61, 0x70, + 0x69, 0x5f, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0b, 0x61, 0x70, 0x69, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x29, 0x0a, + 0x10, 0x69, 0x6e, 0x67, 0x72, 0x65, 0x73, 0x73, 0x5f, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, + 0x74, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x69, 0x6e, 0x67, 0x72, 0x65, 0x73, 0x73, + 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x53, 0x0a, 0x15, 0x73, 0x74, 0x61, 0x74, + 0x65, 0x5f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x69, 0x6d, + 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, + 0x61, 0x6d, 0x70, 0x48, 0x00, 0x52, 0x13, 0x73, 0x74, 0x61, 0x74, 0x65, 0x54, 0x72, 0x61, 0x6e, + 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x88, 0x01, 0x01, 0x1a, 0x5c, 0x0a, + 0x0d, 0x4e, 0x6f, 0x64, 0x65, 0x53, 0x65, 0x74, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, + 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, + 0x12, 0x35, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x1f, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, + 0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x4e, 0x6f, 0x64, 0x65, 0x53, 0x65, 0x74, + 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, 0x18, 0x0a, 0x16, 0x5f, + 0x73, 0x74, 0x61, 0x74, 0x65, 0x5f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, + 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x22, 0xa8, 0x02, 0x0a, 0x10, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, + 0x72, 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x39, 0x0a, 0x04, 0x74, 0x79, + 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x25, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74, - 0x65, 0x72, 0x4e, 0x6f, 0x64, 0x65, 0x53, 0x65, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, - 0x3a, 0x02, 0x38, 0x01, 0x42, 0x18, 0x0a, 0x16, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x65, 0x5f, 0x74, - 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x22, 0xa8, - 0x02, 0x0a, 0x10, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, - 0x69, 0x6f, 0x6e, 0x12, 0x39, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x0e, 0x32, 0x25, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, - 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x64, 0x69, - 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x38, - 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x20, - 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, - 0x2e, 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, - 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x4c, 0x0a, 0x14, 0x6c, 0x61, 0x73, 0x74, - 0x5f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x69, 0x6d, 0x65, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, - 0x6d, 0x70, 0x52, 0x12, 0x6c, 0x61, 0x73, 0x74, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, - 0x6f, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x1b, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, - 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, - 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x05, - 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x88, - 0x01, 0x01, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x42, 0x0a, 0x0a, - 0x08, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0xb0, 0x01, 0x0a, 0x18, 0x43, 0x6c, - 0x75, 0x73, 0x74, 0x65, 0x72, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x74, 0x74, 0x61, - 0x63, 0x68, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x3d, 0x0a, 0x06, 0x73, 0x75, 0x62, 0x6e, 0x65, 0x74, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, - 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x75, 0x62, 0x6e, 0x65, 0x74, 0x4c, - 0x6f, 0x63, 0x61, 0x6c, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x06, 0x73, - 0x75, 0x62, 0x6e, 0x65, 0x74, 0x12, 0x55, 0x0a, 0x0f, 0x73, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, - 0x79, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2c, - 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, - 0x2e, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x4c, 0x6f, - 0x63, 0x61, 0x6c, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x0e, 0x73, 0x65, - 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x22, 0x65, 0x0a, 0x0e, - 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x4e, 0x6f, 0x64, 0x65, 0x53, 0x65, 0x74, 0x12, 0x3f, - 0x0a, 0x09, 0x68, 0x6f, 0x73, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x22, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, - 0x2e, 0x76, 0x31, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x54, 0x79, 0x70, 0x65, 0x52, 0x65, 0x66, 0x65, - 0x72, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x54, 0x79, 0x70, 0x65, 0x12, - 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x04, 0x73, - 0x69, 0x7a, 0x65, 0x22, 0x3b, 0x0a, 0x15, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x4c, 0x6f, - 0x63, 0x61, 0x6c, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x0e, 0x0a, 0x02, - 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, - 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, - 0x22, 0x70, 0x0a, 0x18, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x54, 0x65, 0x6d, 0x70, 0x6c, - 0x61, 0x74, 0x65, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x0e, 0x0a, 0x02, - 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, - 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, - 0x12, 0x18, 0x0a, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x68, - 0x61, 0x72, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x73, 0x68, 0x61, 0x72, - 0x65, 0x64, 0x22, 0x73, 0x0a, 0x1b, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x43, 0x61, 0x74, - 0x61, 0x6c, 0x6f, 0x67, 0x49, 0x74, 0x65, 0x6d, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, - 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, - 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x12, - 0x16, 0x0a, 0x06, 0x73, 0x68, 0x61, 0x72, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x06, 0x73, 0x68, 0x61, 0x72, 0x65, 0x64, 0x2a, 0xbc, 0x01, 0x0a, 0x0c, 0x43, 0x6c, 0x75, 0x73, - 0x74, 0x65, 0x72, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x1d, 0x0a, 0x19, 0x43, 0x4c, 0x55, 0x53, - 0x54, 0x45, 0x52, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, - 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x1d, 0x0a, 0x19, 0x43, 0x4c, 0x55, 0x53, 0x54, - 0x45, 0x52, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x50, 0x52, 0x4f, 0x47, 0x52, 0x45, 0x53, - 0x53, 0x49, 0x4e, 0x47, 0x10, 0x01, 0x12, 0x17, 0x0a, 0x13, 0x43, 0x4c, 0x55, 0x53, 0x54, 0x45, - 0x52, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x52, 0x45, 0x41, 0x44, 0x59, 0x10, 0x02, 0x12, - 0x18, 0x0a, 0x14, 0x43, 0x4c, 0x55, 0x53, 0x54, 0x45, 0x52, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, - 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x03, 0x12, 0x1a, 0x0a, 0x16, 0x43, 0x4c, 0x55, - 0x53, 0x54, 0x45, 0x52, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x44, 0x45, 0x4c, 0x45, 0x54, - 0x49, 0x4e, 0x47, 0x10, 0x04, 0x12, 0x1f, 0x0a, 0x1b, 0x43, 0x4c, 0x55, 0x53, 0x54, 0x45, 0x52, - 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x5f, 0x46, 0x41, - 0x49, 0x4c, 0x45, 0x44, 0x10, 0x05, 0x2a, 0xd0, 0x01, 0x0a, 0x14, 0x43, 0x6c, 0x75, 0x73, 0x74, - 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x12, - 0x26, 0x0a, 0x22, 0x43, 0x4c, 0x55, 0x53, 0x54, 0x45, 0x52, 0x5f, 0x43, 0x4f, 0x4e, 0x44, 0x49, - 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, - 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x26, 0x0a, 0x22, 0x43, 0x4c, 0x55, 0x53, 0x54, - 0x45, 0x52, 0x5f, 0x43, 0x4f, 0x4e, 0x44, 0x49, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59, 0x50, - 0x45, 0x5f, 0x50, 0x52, 0x4f, 0x47, 0x52, 0x45, 0x53, 0x53, 0x49, 0x4e, 0x47, 0x10, 0x01, 0x12, - 0x20, 0x0a, 0x1c, 0x43, 0x4c, 0x55, 0x53, 0x54, 0x45, 0x52, 0x5f, 0x43, 0x4f, 0x4e, 0x44, 0x49, - 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x52, 0x45, 0x41, 0x44, 0x59, 0x10, - 0x02, 0x12, 0x21, 0x0a, 0x1d, 0x43, 0x4c, 0x55, 0x53, 0x54, 0x45, 0x52, 0x5f, 0x43, 0x4f, 0x4e, - 0x44, 0x49, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x46, 0x41, 0x49, 0x4c, - 0x45, 0x44, 0x10, 0x03, 0x12, 0x23, 0x0a, 0x1f, 0x43, 0x4c, 0x55, 0x53, 0x54, 0x45, 0x52, 0x5f, - 0x43, 0x4f, 0x4e, 0x44, 0x49, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x44, - 0x45, 0x47, 0x52, 0x41, 0x44, 0x45, 0x44, 0x10, 0x04, 0x42, 0xd5, 0x01, 0x0a, 0x13, 0x63, 0x6f, - 0x6d, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, - 0x31, 0x42, 0x10, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x54, 0x79, 0x70, 0x65, 0x50, 0x72, - 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x4c, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, - 0x6d, 0x2f, 0x6f, 0x73, 0x61, 0x63, 0x2d, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x2f, 0x6f, - 0x73, 0x61, 0x63, 0x2d, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x69, 0x6e, 0x67, 0x2f, 0x69, 0x6e, 0x74, - 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x6f, 0x73, 0x61, 0x63, 0x2f, 0x70, - 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x31, 0x3b, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, - 0x65, 0x76, 0x31, 0xa2, 0x02, 0x03, 0x4f, 0x50, 0x58, 0xaa, 0x02, 0x0f, 0x4f, 0x73, 0x61, 0x63, - 0x2e, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x56, 0x31, 0xca, 0x02, 0x10, 0x4f, 0x73, - 0x61, 0x63, 0x5c, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, 0x5c, 0x56, 0x31, 0xe2, 0x02, - 0x1c, 0x4f, 0x73, 0x61, 0x63, 0x5c, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, 0x5c, 0x56, - 0x31, 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0xea, 0x02, 0x11, - 0x4f, 0x73, 0x61, 0x63, 0x3a, 0x3a, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x3a, 0x3a, 0x56, - 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x52, + 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x38, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x20, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, + 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, + 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, + 0x4c, 0x0a, 0x14, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, + 0x6f, 0x6e, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, + 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, + 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x12, 0x6c, 0x61, 0x73, 0x74, 0x54, + 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x1b, 0x0a, + 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, + 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x6d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x07, 0x6d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x88, 0x01, 0x01, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x72, 0x65, + 0x61, 0x73, 0x6f, 0x6e, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x22, 0xb0, 0x01, 0x0a, 0x18, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x4e, 0x65, 0x74, 0x77, + 0x6f, 0x72, 0x6b, 0x41, 0x74, 0x74, 0x61, 0x63, 0x68, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x3d, 0x0a, + 0x06, 0x73, 0x75, 0x62, 0x6e, 0x65, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, + 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, + 0x53, 0x75, 0x62, 0x6e, 0x65, 0x74, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x52, 0x65, 0x66, 0x65, 0x72, + 0x65, 0x6e, 0x63, 0x65, 0x52, 0x06, 0x73, 0x75, 0x62, 0x6e, 0x65, 0x74, 0x12, 0x55, 0x0a, 0x0f, + 0x73, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, + 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2c, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, + 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, + 0x47, 0x72, 0x6f, 0x75, 0x70, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, + 0x6e, 0x63, 0x65, 0x52, 0x0e, 0x73, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x47, 0x72, 0x6f, + 0x75, 0x70, 0x73, 0x22, 0x65, 0x0a, 0x0e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x4e, 0x6f, + 0x64, 0x65, 0x53, 0x65, 0x74, 0x12, 0x3f, 0x0a, 0x09, 0x68, 0x6f, 0x73, 0x74, 0x5f, 0x74, 0x79, + 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, + 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x54, + 0x79, 0x70, 0x65, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x08, 0x68, 0x6f, + 0x73, 0x74, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x05, 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x22, 0x3b, 0x0a, 0x15, 0x43, 0x6c, + 0x75, 0x73, 0x74, 0x65, 0x72, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, + 0x6e, 0x63, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x70, 0x0a, 0x18, 0x43, 0x6c, 0x75, 0x73, 0x74, + 0x65, 0x72, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, + 0x6e, 0x63, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, + 0x63, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, + 0x74, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x68, 0x61, 0x72, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x06, 0x73, 0x68, 0x61, 0x72, 0x65, 0x64, 0x22, 0x73, 0x0a, 0x1b, 0x43, 0x6c, 0x75, + 0x73, 0x74, 0x65, 0x72, 0x43, 0x61, 0x74, 0x61, 0x6c, 0x6f, 0x67, 0x49, 0x74, 0x65, 0x6d, 0x52, + 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, + 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x70, + 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x68, 0x61, 0x72, 0x65, 0x64, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x73, 0x68, 0x61, 0x72, 0x65, 0x64, 0x2a, 0xbc, + 0x01, 0x0a, 0x0c, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, + 0x1d, 0x0a, 0x19, 0x43, 0x4c, 0x55, 0x53, 0x54, 0x45, 0x52, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, + 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x1d, + 0x0a, 0x19, 0x43, 0x4c, 0x55, 0x53, 0x54, 0x45, 0x52, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, + 0x50, 0x52, 0x4f, 0x47, 0x52, 0x45, 0x53, 0x53, 0x49, 0x4e, 0x47, 0x10, 0x01, 0x12, 0x17, 0x0a, + 0x13, 0x43, 0x4c, 0x55, 0x53, 0x54, 0x45, 0x52, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x52, + 0x45, 0x41, 0x44, 0x59, 0x10, 0x02, 0x12, 0x18, 0x0a, 0x14, 0x43, 0x4c, 0x55, 0x53, 0x54, 0x45, + 0x52, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x03, + 0x12, 0x1a, 0x0a, 0x16, 0x43, 0x4c, 0x55, 0x53, 0x54, 0x45, 0x52, 0x5f, 0x53, 0x54, 0x41, 0x54, + 0x45, 0x5f, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x49, 0x4e, 0x47, 0x10, 0x04, 0x12, 0x1f, 0x0a, 0x1b, + 0x43, 0x4c, 0x55, 0x53, 0x54, 0x45, 0x52, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x44, 0x45, + 0x4c, 0x45, 0x54, 0x45, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x05, 0x2a, 0xd0, 0x01, + 0x0a, 0x14, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, + 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x12, 0x26, 0x0a, 0x22, 0x43, 0x4c, 0x55, 0x53, 0x54, 0x45, + 0x52, 0x5f, 0x43, 0x4f, 0x4e, 0x44, 0x49, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59, 0x50, 0x45, + 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x26, + 0x0a, 0x22, 0x43, 0x4c, 0x55, 0x53, 0x54, 0x45, 0x52, 0x5f, 0x43, 0x4f, 0x4e, 0x44, 0x49, 0x54, + 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x50, 0x52, 0x4f, 0x47, 0x52, 0x45, 0x53, + 0x53, 0x49, 0x4e, 0x47, 0x10, 0x01, 0x12, 0x20, 0x0a, 0x1c, 0x43, 0x4c, 0x55, 0x53, 0x54, 0x45, + 0x52, 0x5f, 0x43, 0x4f, 0x4e, 0x44, 0x49, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59, 0x50, 0x45, + 0x5f, 0x52, 0x45, 0x41, 0x44, 0x59, 0x10, 0x02, 0x12, 0x21, 0x0a, 0x1d, 0x43, 0x4c, 0x55, 0x53, + 0x54, 0x45, 0x52, 0x5f, 0x43, 0x4f, 0x4e, 0x44, 0x49, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59, + 0x50, 0x45, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x03, 0x12, 0x23, 0x0a, 0x1f, 0x43, + 0x4c, 0x55, 0x53, 0x54, 0x45, 0x52, 0x5f, 0x43, 0x4f, 0x4e, 0x44, 0x49, 0x54, 0x49, 0x4f, 0x4e, + 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x44, 0x45, 0x47, 0x52, 0x41, 0x44, 0x45, 0x44, 0x10, 0x04, + 0x42, 0xd5, 0x01, 0x0a, 0x13, 0x63, 0x6f, 0x6d, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, + 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x42, 0x10, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, + 0x72, 0x54, 0x79, 0x70, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x4c, 0x67, 0x69, + 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6f, 0x73, 0x61, 0x63, 0x2d, 0x70, 0x72, + 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x2f, 0x6f, 0x73, 0x61, 0x63, 0x2d, 0x6d, 0x65, 0x74, 0x65, 0x72, + 0x69, 0x6e, 0x67, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x61, 0x70, 0x69, + 0x2f, 0x6f, 0x73, 0x61, 0x63, 0x2f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x31, + 0x3b, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x76, 0x31, 0xa2, 0x02, 0x03, 0x4f, 0x50, 0x58, + 0xaa, 0x02, 0x0f, 0x4f, 0x73, 0x61, 0x63, 0x2e, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, + 0x56, 0x31, 0xca, 0x02, 0x10, 0x4f, 0x73, 0x61, 0x63, 0x5c, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, + 0x65, 0x5f, 0x5c, 0x56, 0x31, 0xe2, 0x02, 0x1c, 0x4f, 0x73, 0x61, 0x63, 0x5c, 0x50, 0x72, 0x69, + 0x76, 0x61, 0x74, 0x65, 0x5f, 0x5c, 0x56, 0x31, 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65, 0x74, 0x61, + 0x64, 0x61, 0x74, 0x61, 0xea, 0x02, 0x11, 0x4f, 0x73, 0x61, 0x63, 0x3a, 0x3a, 0x50, 0x72, 0x69, + 0x76, 0x61, 0x74, 0x65, 0x3a, 0x3a, 0x56, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, }) var ( @@ -1162,12 +1165,13 @@ var file_osac_private_v1_cluster_type_proto_goTypes = []any{ nil, // 13: osac.private.v1.ClusterSpec.NodeSetsEntry nil, // 14: osac.private.v1.ClusterStatus.NodeSetsEntry (*Metadata)(nil), // 15: osac.private.v1.Metadata - (*timestamppb.Timestamp)(nil), // 16: google.protobuf.Timestamp - (ConditionStatus)(0), // 17: osac.private.v1.ConditionStatus - (*SubnetLocalReference)(nil), // 18: osac.private.v1.SubnetLocalReference - (*SecurityGroupLocalReference)(nil), // 19: osac.private.v1.SecurityGroupLocalReference - (*HostTypeReference)(nil), // 20: osac.private.v1.HostTypeReference - (*anypb.Any)(nil), // 21: google.protobuf.Any + (*ClusterVersionReference)(nil), // 16: osac.private.v1.ClusterVersionReference + (*timestamppb.Timestamp)(nil), // 17: google.protobuf.Timestamp + (ConditionStatus)(0), // 18: osac.private.v1.ConditionStatus + (*SubnetLocalReference)(nil), // 19: osac.private.v1.SubnetLocalReference + (*SecurityGroupLocalReference)(nil), // 20: osac.private.v1.SecurityGroupLocalReference + (*HostTypeReference)(nil), // 21: osac.private.v1.HostTypeReference + (*anypb.Any)(nil), // 22: google.protobuf.Any } var file_osac_private_v1_cluster_type_proto_depIdxs = []int32{ 15, // 0: osac.private.v1.Cluster.metadata:type_name -> osac.private.v1.Metadata @@ -1176,27 +1180,28 @@ var file_osac_private_v1_cluster_type_proto_depIdxs = []int32{ 10, // 3: osac.private.v1.ClusterSpec.template:type_name -> osac.private.v1.ClusterTemplateReference 12, // 4: osac.private.v1.ClusterSpec.template_parameters:type_name -> osac.private.v1.ClusterSpec.TemplateParametersEntry 13, // 5: osac.private.v1.ClusterSpec.node_sets:type_name -> osac.private.v1.ClusterSpec.NodeSetsEntry - 4, // 6: osac.private.v1.ClusterSpec.network:type_name -> osac.private.v1.ClusterNetwork - 11, // 7: osac.private.v1.ClusterSpec.catalog_item:type_name -> osac.private.v1.ClusterCatalogItemReference - 7, // 8: osac.private.v1.ClusterSpec.network_attachment:type_name -> osac.private.v1.ClusterNetworkAttachment - 0, // 9: osac.private.v1.ClusterStatus.state:type_name -> osac.private.v1.ClusterState - 6, // 10: osac.private.v1.ClusterStatus.conditions:type_name -> osac.private.v1.ClusterCondition - 14, // 11: osac.private.v1.ClusterStatus.node_sets:type_name -> osac.private.v1.ClusterStatus.NodeSetsEntry - 16, // 12: osac.private.v1.ClusterStatus.state_transition_time:type_name -> google.protobuf.Timestamp - 1, // 13: osac.private.v1.ClusterCondition.type:type_name -> osac.private.v1.ClusterConditionType - 17, // 14: osac.private.v1.ClusterCondition.status:type_name -> osac.private.v1.ConditionStatus - 16, // 15: osac.private.v1.ClusterCondition.last_transition_time:type_name -> google.protobuf.Timestamp - 18, // 16: osac.private.v1.ClusterNetworkAttachment.subnet:type_name -> osac.private.v1.SubnetLocalReference - 19, // 17: osac.private.v1.ClusterNetworkAttachment.security_groups:type_name -> osac.private.v1.SecurityGroupLocalReference - 20, // 18: osac.private.v1.ClusterNodeSet.host_type:type_name -> osac.private.v1.HostTypeReference - 21, // 19: osac.private.v1.ClusterSpec.TemplateParametersEntry.value:type_name -> google.protobuf.Any - 8, // 20: osac.private.v1.ClusterSpec.NodeSetsEntry.value:type_name -> osac.private.v1.ClusterNodeSet - 8, // 21: osac.private.v1.ClusterStatus.NodeSetsEntry.value:type_name -> osac.private.v1.ClusterNodeSet - 22, // [22:22] is the sub-list for method output_type - 22, // [22:22] is the sub-list for method input_type - 22, // [22:22] is the sub-list for extension type_name - 22, // [22:22] is the sub-list for extension extendee - 0, // [0:22] is the sub-list for field type_name + 16, // 6: osac.private.v1.ClusterSpec.version:type_name -> osac.private.v1.ClusterVersionReference + 4, // 7: osac.private.v1.ClusterSpec.network:type_name -> osac.private.v1.ClusterNetwork + 11, // 8: osac.private.v1.ClusterSpec.catalog_item:type_name -> osac.private.v1.ClusterCatalogItemReference + 7, // 9: osac.private.v1.ClusterSpec.network_attachment:type_name -> osac.private.v1.ClusterNetworkAttachment + 0, // 10: osac.private.v1.ClusterStatus.state:type_name -> osac.private.v1.ClusterState + 6, // 11: osac.private.v1.ClusterStatus.conditions:type_name -> osac.private.v1.ClusterCondition + 14, // 12: osac.private.v1.ClusterStatus.node_sets:type_name -> osac.private.v1.ClusterStatus.NodeSetsEntry + 17, // 13: osac.private.v1.ClusterStatus.state_transition_time:type_name -> google.protobuf.Timestamp + 1, // 14: osac.private.v1.ClusterCondition.type:type_name -> osac.private.v1.ClusterConditionType + 18, // 15: osac.private.v1.ClusterCondition.status:type_name -> osac.private.v1.ConditionStatus + 17, // 16: osac.private.v1.ClusterCondition.last_transition_time:type_name -> google.protobuf.Timestamp + 19, // 17: osac.private.v1.ClusterNetworkAttachment.subnet:type_name -> osac.private.v1.SubnetLocalReference + 20, // 18: osac.private.v1.ClusterNetworkAttachment.security_groups:type_name -> osac.private.v1.SecurityGroupLocalReference + 21, // 19: osac.private.v1.ClusterNodeSet.host_type:type_name -> osac.private.v1.HostTypeReference + 22, // 20: osac.private.v1.ClusterSpec.TemplateParametersEntry.value:type_name -> google.protobuf.Any + 8, // 21: osac.private.v1.ClusterSpec.NodeSetsEntry.value:type_name -> osac.private.v1.ClusterNodeSet + 8, // 22: osac.private.v1.ClusterStatus.NodeSetsEntry.value:type_name -> osac.private.v1.ClusterNodeSet + 23, // [23:23] is the sub-list for method output_type + 23, // [23:23] is the sub-list for method input_type + 23, // [23:23] is the sub-list for extension type_name + 23, // [23:23] is the sub-list for extension extendee + 0, // [0:23] is the sub-list for field type_name } func init() { file_osac_private_v1_cluster_type_proto_init() } @@ -1204,6 +1209,7 @@ func file_osac_private_v1_cluster_type_proto_init() { if File_osac_private_v1_cluster_type_proto != nil { return } + file_osac_private_v1_cluster_version_type_proto_init() file_osac_private_v1_condition_status_type_proto_init() file_osac_private_v1_metadata_type_proto_init() file_osac_private_v1_host_type_type_proto_init() diff --git a/osac-metering/metering-service/internal/api/osac/private/v1/cluster_version_type.pb.go b/osac-metering/metering-service/internal/api/osac/private/v1/cluster_version_type.pb.go index eaa4a3514..60125db18 100644 --- a/osac-metering/metering-service/internal/api/osac/private/v1/cluster_version_type.pb.go +++ b/osac-metering/metering-service/internal/api/osac/private/v1/cluster_version_type.pb.go @@ -168,8 +168,8 @@ func (x *ClusterVersionDeprecation) GetObsolescenceTimestamp() *timestamppb.Time // Contains the details about the cluster version that are available only for the system. // // Cluster versions are admin-managed catalog entries that define which OpenShift versions are available. Users -// select a cluster version by name when creating clusters, either directly via Cluster `spec.version_name` or -// through a ClusterTemplate `spec_defaults.version_name`. Cloud Provider Admins control the version catalog +// select a cluster version when creating clusters, either directly via Cluster `spec.version` or +// through a ClusterTemplate `spec_defaults.version`. Cloud Provider Admins control the version catalog // through lifecycle-managed cluster versions. // // If `metadata.name` is omitted on creation, it is auto-generated from `spec.version` by lowercasing and @@ -274,7 +274,7 @@ type ClusterVersionSpec struct { // the previous default atomically. Auto-cleared when the version becomes OBSOLETE or disabled. // // When creating a cluster, the version is resolved with the following precedence: - // explicit `spec.version_name` > template `spec_defaults.version_name` > system default (`is_default=true`). + // explicit `spec.version` > template `spec_defaults.version` > system default (`is_default=true`). IsDefault *bool `protobuf:"varint,3,opt,name=is_default,json=isDefault,proto3,oneof" json:"is_default,omitempty"` // Current lifecycle state of the cluster version. Defaults to ACTIVE on creation if unspecified. State ClusterVersionState `protobuf:"varint,4,opt,name=state,proto3,enum=osac.private.v1.ClusterVersionState" json:"state,omitempty"` @@ -402,6 +402,75 @@ func (*ClusterVersionStatus) Descriptor() ([]byte, []int) { return file_osac_private_v1_cluster_version_type_proto_rawDescGZIP(), []int{3} } +// Reference to a ClusterVersion resource. +type ClusterVersionReference struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Project string `protobuf:"bytes,3,opt,name=project,proto3" json:"project,omitempty"` + Shared bool `protobuf:"varint,4,opt,name=shared,proto3" json:"shared,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ClusterVersionReference) Reset() { + *x = ClusterVersionReference{} + mi := &file_osac_private_v1_cluster_version_type_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ClusterVersionReference) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ClusterVersionReference) ProtoMessage() {} + +func (x *ClusterVersionReference) ProtoReflect() protoreflect.Message { + mi := &file_osac_private_v1_cluster_version_type_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ClusterVersionReference.ProtoReflect.Descriptor instead. +func (*ClusterVersionReference) Descriptor() ([]byte, []int) { + return file_osac_private_v1_cluster_version_type_proto_rawDescGZIP(), []int{4} +} + +func (x *ClusterVersionReference) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ClusterVersionReference) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ClusterVersionReference) GetProject() string { + if x != nil { + return x.Project + } + return "" +} + +func (x *ClusterVersionReference) GetShared() bool { + if x != nil { + return x.Shared + } + return false +} + var File_osac_private_v1_cluster_version_type_proto protoreflect.FileDescriptor var file_osac_private_v1_cluster_version_type_proto_rawDesc = string([]byte{ @@ -466,32 +535,40 @@ var file_osac_private_v1_cluster_version_type_proto_rawDesc = string([]byte{ 0x80, 0x02, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x42, 0x0d, 0x0a, 0x0b, 0x5f, 0x69, 0x73, 0x5f, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x22, 0x16, 0x0a, 0x14, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, - 0x72, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x2a, 0xa8, - 0x01, 0x0a, 0x13, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, - 0x6e, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x25, 0x0a, 0x21, 0x43, 0x4c, 0x55, 0x53, 0x54, 0x45, + 0x72, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x6f, + 0x0a, 0x17, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, + 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, + 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, + 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, + 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x68, 0x61, 0x72, 0x65, + 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x73, 0x68, 0x61, 0x72, 0x65, 0x64, 0x2a, + 0xa8, 0x01, 0x0a, 0x13, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x56, 0x65, 0x72, 0x73, 0x69, + 0x6f, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x25, 0x0a, 0x21, 0x43, 0x4c, 0x55, 0x53, 0x54, + 0x45, 0x52, 0x5f, 0x56, 0x45, 0x52, 0x53, 0x49, 0x4f, 0x4e, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, + 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x20, + 0x0a, 0x1c, 0x43, 0x4c, 0x55, 0x53, 0x54, 0x45, 0x52, 0x5f, 0x56, 0x45, 0x52, 0x53, 0x49, 0x4f, + 0x4e, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x41, 0x43, 0x54, 0x49, 0x56, 0x45, 0x10, 0x01, + 0x12, 0x24, 0x0a, 0x20, 0x43, 0x4c, 0x55, 0x53, 0x54, 0x45, 0x52, 0x5f, 0x56, 0x45, 0x52, 0x53, + 0x49, 0x4f, 0x4e, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x44, 0x45, 0x50, 0x52, 0x45, 0x43, + 0x41, 0x54, 0x45, 0x44, 0x10, 0x02, 0x12, 0x22, 0x0a, 0x1e, 0x43, 0x4c, 0x55, 0x53, 0x54, 0x45, 0x52, 0x5f, 0x56, 0x45, 0x52, 0x53, 0x49, 0x4f, 0x4e, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, - 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x20, 0x0a, - 0x1c, 0x43, 0x4c, 0x55, 0x53, 0x54, 0x45, 0x52, 0x5f, 0x56, 0x45, 0x52, 0x53, 0x49, 0x4f, 0x4e, - 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x41, 0x43, 0x54, 0x49, 0x56, 0x45, 0x10, 0x01, 0x12, - 0x24, 0x0a, 0x20, 0x43, 0x4c, 0x55, 0x53, 0x54, 0x45, 0x52, 0x5f, 0x56, 0x45, 0x52, 0x53, 0x49, - 0x4f, 0x4e, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x44, 0x45, 0x50, 0x52, 0x45, 0x43, 0x41, - 0x54, 0x45, 0x44, 0x10, 0x02, 0x12, 0x22, 0x0a, 0x1e, 0x43, 0x4c, 0x55, 0x53, 0x54, 0x45, 0x52, - 0x5f, 0x56, 0x45, 0x52, 0x53, 0x49, 0x4f, 0x4e, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x4f, - 0x42, 0x53, 0x4f, 0x4c, 0x45, 0x54, 0x45, 0x10, 0x03, 0x42, 0xdc, 0x01, 0x0a, 0x13, 0x63, 0x6f, - 0x6d, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, - 0x31, 0x42, 0x17, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, - 0x6e, 0x54, 0x79, 0x70, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x4c, 0x67, 0x69, - 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6f, 0x73, 0x61, 0x63, 0x2d, 0x70, 0x72, - 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x2f, 0x6f, 0x73, 0x61, 0x63, 0x2d, 0x6d, 0x65, 0x74, 0x65, 0x72, - 0x69, 0x6e, 0x67, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x61, 0x70, 0x69, - 0x2f, 0x6f, 0x73, 0x61, 0x63, 0x2f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x31, - 0x3b, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x76, 0x31, 0xa2, 0x02, 0x03, 0x4f, 0x50, 0x58, - 0xaa, 0x02, 0x0f, 0x4f, 0x73, 0x61, 0x63, 0x2e, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, - 0x56, 0x31, 0xca, 0x02, 0x10, 0x4f, 0x73, 0x61, 0x63, 0x5c, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, - 0x65, 0x5f, 0x5c, 0x56, 0x31, 0xe2, 0x02, 0x1c, 0x4f, 0x73, 0x61, 0x63, 0x5c, 0x50, 0x72, 0x69, - 0x76, 0x61, 0x74, 0x65, 0x5f, 0x5c, 0x56, 0x31, 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65, 0x74, 0x61, - 0x64, 0x61, 0x74, 0x61, 0xea, 0x02, 0x11, 0x4f, 0x73, 0x61, 0x63, 0x3a, 0x3a, 0x50, 0x72, 0x69, - 0x76, 0x61, 0x74, 0x65, 0x3a, 0x3a, 0x56, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x4f, 0x42, 0x53, 0x4f, 0x4c, 0x45, 0x54, 0x45, 0x10, 0x03, 0x42, 0xdc, 0x01, 0x0a, 0x13, 0x63, + 0x6f, 0x6d, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, + 0x76, 0x31, 0x42, 0x17, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x56, 0x65, 0x72, 0x73, 0x69, + 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x4c, 0x67, + 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6f, 0x73, 0x61, 0x63, 0x2d, 0x70, + 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x2f, 0x6f, 0x73, 0x61, 0x63, 0x2d, 0x6d, 0x65, 0x74, 0x65, + 0x72, 0x69, 0x6e, 0x67, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x61, 0x70, + 0x69, 0x2f, 0x6f, 0x73, 0x61, 0x63, 0x2f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2f, 0x76, + 0x31, 0x3b, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x76, 0x31, 0xa2, 0x02, 0x03, 0x4f, 0x50, + 0x58, 0xaa, 0x02, 0x0f, 0x4f, 0x73, 0x61, 0x63, 0x2e, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, + 0x2e, 0x56, 0x31, 0xca, 0x02, 0x10, 0x4f, 0x73, 0x61, 0x63, 0x5c, 0x50, 0x72, 0x69, 0x76, 0x61, + 0x74, 0x65, 0x5f, 0x5c, 0x56, 0x31, 0xe2, 0x02, 0x1c, 0x4f, 0x73, 0x61, 0x63, 0x5c, 0x50, 0x72, + 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, 0x5c, 0x56, 0x31, 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65, 0x74, + 0x61, 0x64, 0x61, 0x74, 0x61, 0xea, 0x02, 0x11, 0x4f, 0x73, 0x61, 0x63, 0x3a, 0x3a, 0x50, 0x72, + 0x69, 0x76, 0x61, 0x74, 0x65, 0x3a, 0x3a, 0x56, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x33, }) var ( @@ -507,20 +584,21 @@ func file_osac_private_v1_cluster_version_type_proto_rawDescGZIP() []byte { } var file_osac_private_v1_cluster_version_type_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_osac_private_v1_cluster_version_type_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_osac_private_v1_cluster_version_type_proto_msgTypes = make([]protoimpl.MessageInfo, 5) var file_osac_private_v1_cluster_version_type_proto_goTypes = []any{ (ClusterVersionState)(0), // 0: osac.private.v1.ClusterVersionState (*ClusterVersionDeprecation)(nil), // 1: osac.private.v1.ClusterVersionDeprecation (*ClusterVersion)(nil), // 2: osac.private.v1.ClusterVersion (*ClusterVersionSpec)(nil), // 3: osac.private.v1.ClusterVersionSpec (*ClusterVersionStatus)(nil), // 4: osac.private.v1.ClusterVersionStatus - (*timestamppb.Timestamp)(nil), // 5: google.protobuf.Timestamp - (*Metadata)(nil), // 6: osac.private.v1.Metadata + (*ClusterVersionReference)(nil), // 5: osac.private.v1.ClusterVersionReference + (*timestamppb.Timestamp)(nil), // 6: google.protobuf.Timestamp + (*Metadata)(nil), // 7: osac.private.v1.Metadata } var file_osac_private_v1_cluster_version_type_proto_depIdxs = []int32{ - 5, // 0: osac.private.v1.ClusterVersionDeprecation.deprecation_timestamp:type_name -> google.protobuf.Timestamp - 5, // 1: osac.private.v1.ClusterVersionDeprecation.obsolescence_timestamp:type_name -> google.protobuf.Timestamp - 6, // 2: osac.private.v1.ClusterVersion.metadata:type_name -> osac.private.v1.Metadata + 6, // 0: osac.private.v1.ClusterVersionDeprecation.deprecation_timestamp:type_name -> google.protobuf.Timestamp + 6, // 1: osac.private.v1.ClusterVersionDeprecation.obsolescence_timestamp:type_name -> google.protobuf.Timestamp + 7, // 2: osac.private.v1.ClusterVersion.metadata:type_name -> osac.private.v1.Metadata 3, // 3: osac.private.v1.ClusterVersion.spec:type_name -> osac.private.v1.ClusterVersionSpec 4, // 4: osac.private.v1.ClusterVersion.status:type_name -> osac.private.v1.ClusterVersionStatus 0, // 5: osac.private.v1.ClusterVersionSpec.state:type_name -> osac.private.v1.ClusterVersionState @@ -545,7 +623,7 @@ func file_osac_private_v1_cluster_version_type_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_osac_private_v1_cluster_version_type_proto_rawDesc), len(file_osac_private_v1_cluster_version_type_proto_rawDesc)), NumEnums: 1, - NumMessages: 4, + NumMessages: 5, NumExtensions: 0, NumServices: 0, }, diff --git a/osac-metering/metering-service/internal/api/osac/private/v1/compute_instance_type.pb.go b/osac-metering/metering-service/internal/api/osac/private/v1/compute_instance_type.pb.go index 435b72a12..975074b04 100644 --- a/osac-metering/metering-service/internal/api/osac/private/v1/compute_instance_type.pb.go +++ b/osac-metering/metering-service/internal/api/osac/private/v1/compute_instance_type.pb.go @@ -20,6 +20,7 @@ package privatev1 import ( + _ "google.golang.org/genproto/googleapis/api/annotations" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" anypb "google.golang.org/protobuf/types/known/anypb" @@ -415,9 +416,13 @@ type ComputeInstanceSpec struct { // Reference to an instance type. Specifies the compute configuration (cores, memory) // for this instance. The API validates that the instance type exists and is not OBSOLETE; // resolution of cores/memory_gib happens in the reconciler. - InstanceType *InstanceTypeReference `protobuf:"bytes,17,opt,name=instance_type,json=instanceType,proto3" json:"instance_type,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + InstanceType *InstanceTypeReference `protobuf:"bytes,17,opt,name=instance_type,json=instanceType,proto3" json:"instance_type,omitempty"` + // When true, the system auto-selects an ExternalIPPool and creates an ExternalIP + // with an ExternalIPAttachment for this instance atomically during creation. + // Immutable after creation. + AutoExternalIpAttachment bool `protobuf:"varint,18,opt,name=auto_external_ip_attachment,json=autoExternalIpAttachment,proto3" json:"auto_external_ip_attachment,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ComputeInstanceSpec) Reset() { @@ -541,6 +546,13 @@ func (x *ComputeInstanceSpec) GetInstanceType() *InstanceTypeReference { return nil } +func (x *ComputeInstanceSpec) GetAutoExternalIpAttachment() bool { + if x != nil { + return x.AutoExternalIpAttachment + } + return false +} + type ComputeInstanceStatus struct { state protoimpl.MessageState `protogen:"open.v1"` // Public fields. @@ -915,260 +927,266 @@ var file_osac_private_v1_compute_instance_type_proto_rawDesc = string([]byte{ 0x0a, 0x2b, 0x6f, 0x73, 0x61, 0x63, 0x2f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x31, 0x2f, 0x63, 0x6f, 0x6d, 0x70, 0x75, 0x74, 0x65, 0x5f, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0f, 0x6f, - 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x1a, 0x19, - 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, - 0x61, 0x6e, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, - 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, - 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x2b, 0x6f, 0x73, 0x61, 0x63, - 0x2f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x31, 0x2f, 0x63, 0x6f, 0x6e, 0x64, - 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x5f, 0x74, 0x79, 0x70, - 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x23, 0x6f, 0x73, 0x61, 0x63, 0x2f, 0x70, 0x72, - 0x69, 0x76, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x31, 0x2f, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, - 0x61, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x28, 0x6f, 0x73, - 0x61, 0x63, 0x2f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x31, 0x2f, 0x69, 0x6e, - 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x5f, 0x74, 0x79, 0x70, 0x65, - 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x29, 0x6f, 0x73, 0x61, 0x63, 0x2f, 0x70, 0x72, 0x69, - 0x76, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x31, 0x2f, 0x73, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, - 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x1a, 0x21, 0x6f, 0x73, 0x61, 0x63, 0x2f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2f, - 0x76, 0x31, 0x2f, 0x73, 0x75, 0x62, 0x6e, 0x65, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x2e, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xd2, 0x01, 0x0a, 0x0f, 0x43, 0x6f, 0x6d, 0x70, 0x75, 0x74, 0x65, - 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x35, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, - 0x64, 0x61, 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6f, 0x73, 0x61, - 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x65, 0x74, - 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, - 0x38, 0x0a, 0x04, 0x73, 0x70, 0x65, 0x63, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e, - 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, - 0x43, 0x6f, 0x6d, 0x70, 0x75, 0x74, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x53, - 0x70, 0x65, 0x63, 0x52, 0x04, 0x73, 0x70, 0x65, 0x63, 0x12, 0x3e, 0x0a, 0x06, 0x73, 0x74, 0x61, - 0x74, 0x75, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x6f, 0x73, 0x61, 0x63, - 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6d, 0x70, - 0x75, 0x74, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x53, 0x74, 0x61, 0x74, 0x75, - 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x56, 0x0a, 0x14, 0x43, 0x6f, 0x6d, - 0x70, 0x75, 0x74, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x49, 0x6d, 0x61, 0x67, - 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x74, 0x79, 0x70, 0x65, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x79, - 0x70, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x72, 0x65, 0x66, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, - 0x66, 0x22, 0x30, 0x0a, 0x13, 0x43, 0x6f, 0x6d, 0x70, 0x75, 0x74, 0x65, 0x49, 0x6e, 0x73, 0x74, - 0x61, 0x6e, 0x63, 0x65, 0x44, 0x69, 0x73, 0x6b, 0x12, 0x19, 0x0a, 0x08, 0x73, 0x69, 0x7a, 0x65, - 0x5f, 0x67, 0x69, 0x62, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x07, 0x73, 0x69, 0x7a, 0x65, - 0x47, 0x69, 0x62, 0x22, 0xa9, 0x01, 0x0a, 0x11, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, - 0x74, 0x74, 0x61, 0x63, 0x68, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x3d, 0x0a, 0x06, 0x73, 0x75, 0x62, - 0x6e, 0x65, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x6f, 0x73, 0x61, 0x63, - 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x75, 0x62, 0x6e, - 0x65, 0x74, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, - 0x52, 0x06, 0x73, 0x75, 0x62, 0x6e, 0x65, 0x74, 0x12, 0x55, 0x0a, 0x0f, 0x73, 0x65, 0x63, 0x75, - 0x72, 0x69, 0x74, 0x79, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, - 0x0b, 0x32, 0x2c, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, - 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x47, 0x72, 0x6f, 0x75, - 0x70, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x52, - 0x0e, 0x73, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x22, - 0xa8, 0x09, 0x0a, 0x13, 0x43, 0x6f, 0x6d, 0x70, 0x75, 0x74, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, - 0x6e, 0x63, 0x65, 0x53, 0x70, 0x65, 0x63, 0x12, 0x4d, 0x0a, 0x08, 0x74, 0x65, 0x6d, 0x70, 0x6c, - 0x61, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x31, 0x2e, 0x6f, 0x73, 0x61, 0x63, + 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x1a, 0x1f, + 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x66, 0x69, 0x65, 0x6c, 0x64, + 0x5f, 0x62, 0x65, 0x68, 0x61, 0x76, 0x69, 0x6f, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, + 0x19, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, + 0x2f, 0x61, 0x6e, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, + 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, + 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x2b, 0x6f, 0x73, 0x61, + 0x63, 0x2f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x31, 0x2f, 0x63, 0x6f, 0x6e, + 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x5f, 0x74, 0x79, + 0x70, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x23, 0x6f, 0x73, 0x61, 0x63, 0x2f, 0x70, + 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x31, 0x2f, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, + 0x74, 0x61, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x28, 0x6f, + 0x73, 0x61, 0x63, 0x2f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x31, 0x2f, 0x69, + 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x5f, 0x74, 0x79, 0x70, + 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x29, 0x6f, 0x73, 0x61, 0x63, 0x2f, 0x70, 0x72, + 0x69, 0x76, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x31, 0x2f, 0x73, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, + 0x79, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x1a, 0x21, 0x6f, 0x73, 0x61, 0x63, 0x2f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, + 0x2f, 0x76, 0x31, 0x2f, 0x73, 0x75, 0x62, 0x6e, 0x65, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xd2, 0x01, 0x0a, 0x0f, 0x43, 0x6f, 0x6d, 0x70, 0x75, 0x74, + 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x35, 0x0a, 0x08, 0x6d, 0x65, 0x74, + 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6f, 0x73, + 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x65, + 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, + 0x12, 0x38, 0x0a, 0x04, 0x73, 0x70, 0x65, 0x63, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x24, + 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, + 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x75, 0x74, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, + 0x53, 0x70, 0x65, 0x63, 0x52, 0x04, 0x73, 0x70, 0x65, 0x63, 0x12, 0x3e, 0x0a, 0x06, 0x73, 0x74, + 0x61, 0x74, 0x75, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x6f, 0x73, 0x61, + 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6d, + 0x70, 0x75, 0x74, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x53, 0x74, 0x61, 0x74, + 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x56, 0x0a, 0x14, 0x43, 0x6f, + 0x6d, 0x70, 0x75, 0x74, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x49, 0x6d, 0x61, + 0x67, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x74, 0x79, 0x70, + 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, + 0x79, 0x70, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x72, 0x65, + 0x66, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, + 0x65, 0x66, 0x22, 0x30, 0x0a, 0x13, 0x43, 0x6f, 0x6d, 0x70, 0x75, 0x74, 0x65, 0x49, 0x6e, 0x73, + 0x74, 0x61, 0x6e, 0x63, 0x65, 0x44, 0x69, 0x73, 0x6b, 0x12, 0x19, 0x0a, 0x08, 0x73, 0x69, 0x7a, + 0x65, 0x5f, 0x67, 0x69, 0x62, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x07, 0x73, 0x69, 0x7a, + 0x65, 0x47, 0x69, 0x62, 0x22, 0xa9, 0x01, 0x0a, 0x11, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, + 0x41, 0x74, 0x74, 0x61, 0x63, 0x68, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x3d, 0x0a, 0x06, 0x73, 0x75, + 0x62, 0x6e, 0x65, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x6f, 0x73, 0x61, + 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x75, 0x62, + 0x6e, 0x65, 0x74, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, + 0x65, 0x52, 0x06, 0x73, 0x75, 0x62, 0x6e, 0x65, 0x74, 0x12, 0x55, 0x0a, 0x0f, 0x73, 0x65, 0x63, + 0x75, 0x72, 0x69, 0x74, 0x79, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x02, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x2c, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, + 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x47, 0x72, 0x6f, + 0x75, 0x70, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, + 0x52, 0x0e, 0x73, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, + 0x22, 0xec, 0x09, 0x0a, 0x13, 0x43, 0x6f, 0x6d, 0x70, 0x75, 0x74, 0x65, 0x49, 0x6e, 0x73, 0x74, + 0x61, 0x6e, 0x63, 0x65, 0x53, 0x70, 0x65, 0x63, 0x12, 0x4d, 0x0a, 0x08, 0x74, 0x65, 0x6d, 0x70, + 0x6c, 0x61, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x31, 0x2e, 0x6f, 0x73, 0x61, + 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6d, + 0x70, 0x75, 0x74, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x54, 0x65, 0x6d, 0x70, + 0x6c, 0x61, 0x74, 0x65, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x08, 0x74, + 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x12, 0x6d, 0x0a, 0x13, 0x74, 0x65, 0x6d, 0x70, 0x6c, + 0x61, 0x74, 0x65, 0x5f, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x18, 0x02, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3c, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, + 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x75, 0x74, 0x65, 0x49, 0x6e, + 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x53, 0x70, 0x65, 0x63, 0x2e, 0x54, 0x65, 0x6d, 0x70, 0x6c, + 0x61, 0x74, 0x65, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, + 0x72, 0x79, 0x52, 0x12, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x50, 0x61, 0x72, 0x61, + 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x12, 0x51, 0x0a, 0x14, 0x72, 0x65, 0x73, 0x74, 0x61, 0x72, + 0x74, 0x5f, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, + 0x48, 0x00, 0x52, 0x12, 0x72, 0x65, 0x73, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x65, 0x64, 0x41, 0x74, 0x88, 0x01, 0x01, 0x12, 0x40, 0x0a, 0x05, 0x69, 0x6d, 0x61, + 0x67, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, + 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x75, + 0x74, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x48, + 0x01, 0x52, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x88, 0x01, 0x01, 0x12, 0x29, 0x0a, 0x0e, 0x73, + 0x73, 0x68, 0x5f, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x07, 0x20, + 0x01, 0x28, 0x09, 0x48, 0x02, 0x52, 0x0c, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x63, + 0x4b, 0x65, 0x79, 0x88, 0x01, 0x01, 0x12, 0x46, 0x0a, 0x09, 0x62, 0x6f, 0x6f, 0x74, 0x5f, 0x64, + 0x69, 0x73, 0x6b, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6d, 0x70, - 0x75, 0x74, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x54, 0x65, 0x6d, 0x70, 0x6c, - 0x61, 0x74, 0x65, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x08, 0x74, 0x65, - 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x12, 0x6d, 0x0a, 0x13, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, - 0x74, 0x65, 0x5f, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x18, 0x02, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x3c, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, - 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x75, 0x74, 0x65, 0x49, 0x6e, 0x73, - 0x74, 0x61, 0x6e, 0x63, 0x65, 0x53, 0x70, 0x65, 0x63, 0x2e, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, - 0x74, 0x65, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, - 0x79, 0x52, 0x12, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x50, 0x61, 0x72, 0x61, 0x6d, - 0x65, 0x74, 0x65, 0x72, 0x73, 0x12, 0x51, 0x0a, 0x14, 0x72, 0x65, 0x73, 0x74, 0x61, 0x72, 0x74, - 0x5f, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x48, - 0x00, 0x52, 0x12, 0x72, 0x65, 0x73, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x65, 0x64, 0x41, 0x74, 0x88, 0x01, 0x01, 0x12, 0x40, 0x0a, 0x05, 0x69, 0x6d, 0x61, 0x67, - 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, - 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x75, 0x74, - 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x48, 0x01, - 0x52, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x88, 0x01, 0x01, 0x12, 0x29, 0x0a, 0x0e, 0x73, 0x73, - 0x68, 0x5f, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x07, 0x20, 0x01, - 0x28, 0x09, 0x48, 0x02, 0x52, 0x0c, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, - 0x65, 0x79, 0x88, 0x01, 0x01, 0x12, 0x46, 0x0a, 0x09, 0x62, 0x6f, 0x6f, 0x74, 0x5f, 0x64, 0x69, - 0x73, 0x6b, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, + 0x75, 0x74, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x44, 0x69, 0x73, 0x6b, 0x48, + 0x03, 0x52, 0x08, 0x62, 0x6f, 0x6f, 0x74, 0x44, 0x69, 0x73, 0x6b, 0x88, 0x01, 0x01, 0x12, 0x4f, + 0x0a, 0x10, 0x61, 0x64, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x5f, 0x64, 0x69, 0x73, + 0x6b, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x75, - 0x74, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x44, 0x69, 0x73, 0x6b, 0x48, 0x03, - 0x52, 0x08, 0x62, 0x6f, 0x6f, 0x74, 0x44, 0x69, 0x73, 0x6b, 0x88, 0x01, 0x01, 0x12, 0x4f, 0x0a, - 0x10, 0x61, 0x64, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x5f, 0x64, 0x69, 0x73, 0x6b, - 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, - 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x75, 0x74, - 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x44, 0x69, 0x73, 0x6b, 0x52, 0x0f, 0x61, - 0x64, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x44, 0x69, 0x73, 0x6b, 0x73, 0x12, 0x26, - 0x0a, 0x0c, 0x72, 0x75, 0x6e, 0x5f, 0x73, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x18, 0x0a, - 0x20, 0x01, 0x28, 0x09, 0x48, 0x04, 0x52, 0x0b, 0x72, 0x75, 0x6e, 0x53, 0x74, 0x72, 0x61, 0x74, - 0x65, 0x67, 0x79, 0x88, 0x01, 0x01, 0x12, 0x20, 0x0a, 0x09, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x64, - 0x61, 0x74, 0x61, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x48, 0x05, 0x52, 0x08, 0x75, 0x73, 0x65, - 0x72, 0x44, 0x61, 0x74, 0x61, 0x88, 0x01, 0x01, 0x12, 0x53, 0x0a, 0x13, 0x6e, 0x65, 0x74, 0x77, - 0x6f, 0x72, 0x6b, 0x5f, 0x61, 0x74, 0x74, 0x61, 0x63, 0x68, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x18, - 0x0e, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, - 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, - 0x74, 0x74, 0x61, 0x63, 0x68, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x12, 0x6e, 0x65, 0x74, 0x77, 0x6f, - 0x72, 0x6b, 0x41, 0x74, 0x74, 0x61, 0x63, 0x68, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x57, 0x0a, - 0x0c, 0x63, 0x61, 0x74, 0x61, 0x6c, 0x6f, 0x67, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x18, 0x0f, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x34, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, - 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x75, 0x74, 0x65, 0x49, 0x6e, 0x73, - 0x74, 0x61, 0x6e, 0x63, 0x65, 0x43, 0x61, 0x74, 0x61, 0x6c, 0x6f, 0x67, 0x49, 0x74, 0x65, 0x6d, - 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x0b, 0x63, 0x61, 0x74, 0x61, 0x6c, - 0x6f, 0x67, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x22, 0x0a, 0x0a, 0x69, 0x73, 0x5f, 0x77, 0x69, 0x6e, - 0x64, 0x6f, 0x77, 0x73, 0x18, 0x10, 0x20, 0x01, 0x28, 0x08, 0x48, 0x06, 0x52, 0x09, 0x69, 0x73, - 0x57, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x73, 0x88, 0x01, 0x01, 0x12, 0x4b, 0x0a, 0x0d, 0x69, 0x6e, - 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x11, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x26, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, - 0x2e, 0x76, 0x31, 0x2e, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x54, 0x79, 0x70, 0x65, - 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x0c, 0x69, 0x6e, 0x73, 0x74, 0x61, - 0x6e, 0x63, 0x65, 0x54, 0x79, 0x70, 0x65, 0x1a, 0x5b, 0x0a, 0x17, 0x54, 0x65, 0x6d, 0x70, 0x6c, - 0x61, 0x74, 0x65, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, - 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2a, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, - 0x3a, 0x02, 0x38, 0x01, 0x42, 0x17, 0x0a, 0x15, 0x5f, 0x72, 0x65, 0x73, 0x74, 0x61, 0x72, 0x74, - 0x5f, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x42, 0x08, 0x0a, - 0x06, 0x5f, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x42, 0x11, 0x0a, 0x0f, 0x5f, 0x73, 0x73, 0x68, 0x5f, - 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x62, - 0x6f, 0x6f, 0x74, 0x5f, 0x64, 0x69, 0x73, 0x6b, 0x42, 0x0f, 0x0a, 0x0d, 0x5f, 0x72, 0x75, 0x6e, - 0x5f, 0x73, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x75, 0x73, - 0x65, 0x72, 0x5f, 0x64, 0x61, 0x74, 0x61, 0x42, 0x0d, 0x0a, 0x0b, 0x5f, 0x69, 0x73, 0x5f, 0x77, - 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x73, 0x4a, 0x04, 0x08, 0x05, 0x10, 0x06, 0x4a, 0x04, 0x08, 0x06, - 0x10, 0x07, 0x4a, 0x04, 0x08, 0x0c, 0x10, 0x0d, 0x4a, 0x04, 0x08, 0x0d, 0x10, 0x0e, 0x52, 0x05, - 0x63, 0x6f, 0x72, 0x65, 0x73, 0x52, 0x0a, 0x6d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x5f, 0x67, 0x69, - 0x62, 0x52, 0x06, 0x73, 0x75, 0x62, 0x6e, 0x65, 0x74, 0x52, 0x0f, 0x73, 0x65, 0x63, 0x75, 0x72, - 0x69, 0x74, 0x79, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x22, 0xe3, 0x03, 0x0a, 0x15, 0x43, - 0x6f, 0x6d, 0x70, 0x75, 0x74, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x53, 0x74, - 0x61, 0x74, 0x75, 0x73, 0x12, 0x3b, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x0e, 0x32, 0x25, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, - 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x75, 0x74, 0x65, 0x49, 0x6e, 0x73, - 0x74, 0x61, 0x6e, 0x63, 0x65, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, - 0x65, 0x12, 0x49, 0x0a, 0x0a, 0x63, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, - 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, - 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x75, 0x74, 0x65, 0x49, - 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, - 0x52, 0x0a, 0x63, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x2e, 0x0a, 0x13, - 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x5f, 0x69, 0x70, 0x5f, 0x61, 0x64, 0x64, 0x72, - 0x65, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x11, 0x69, 0x6e, 0x74, 0x65, 0x72, - 0x6e, 0x61, 0x6c, 0x49, 0x70, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x10, 0x0a, 0x03, - 0x68, 0x75, 0x62, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x68, 0x75, 0x62, 0x12, 0x4b, - 0x0a, 0x11, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x72, 0x65, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, - 0x5f, 0x61, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, - 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, - 0x73, 0x74, 0x61, 0x6d, 0x70, 0x48, 0x00, 0x52, 0x0f, 0x6c, 0x61, 0x73, 0x74, 0x52, 0x65, 0x73, - 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x41, 0x74, 0x88, 0x01, 0x01, 0x12, 0x2e, 0x0a, 0x13, 0x65, - 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x5f, 0x69, 0x70, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, - 0x73, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x11, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, - 0x61, 0x6c, 0x49, 0x70, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x53, 0x0a, 0x15, 0x73, - 0x74, 0x61, 0x74, 0x65, 0x5f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x5f, - 0x74, 0x69, 0x6d, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, - 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, - 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x48, 0x01, 0x52, 0x13, 0x73, 0x74, 0x61, 0x74, 0x65, 0x54, - 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x88, 0x01, 0x01, - 0x42, 0x14, 0x0a, 0x12, 0x5f, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x72, 0x65, 0x73, 0x74, 0x61, 0x72, - 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x42, 0x18, 0x0a, 0x16, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x65, - 0x5f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x69, 0x6d, 0x65, - 0x22, 0xb8, 0x02, 0x0a, 0x18, 0x43, 0x6f, 0x6d, 0x70, 0x75, 0x74, 0x65, 0x49, 0x6e, 0x73, 0x74, - 0x61, 0x6e, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x41, 0x0a, - 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x2d, 0x2e, 0x6f, 0x73, - 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, - 0x6d, 0x70, 0x75, 0x74, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x43, 0x6f, 0x6e, - 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, - 0x12, 0x38, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, - 0x32, 0x20, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, - 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x74, 0x61, 0x74, - 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x4c, 0x0a, 0x14, 0x6c, 0x61, - 0x73, 0x74, 0x5f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x69, - 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, - 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, - 0x74, 0x61, 0x6d, 0x70, 0x52, 0x12, 0x6c, 0x61, 0x73, 0x74, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x69, - 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x1b, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, - 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, - 0x6f, 0x6e, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, - 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, - 0x65, 0x88, 0x01, 0x01, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x42, - 0x0a, 0x0a, 0x08, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x43, 0x0a, 0x1d, 0x43, - 0x6f, 0x6d, 0x70, 0x75, 0x74, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x4c, 0x6f, - 0x63, 0x61, 0x6c, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x0e, 0x0a, 0x02, - 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, - 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, - 0x22, 0x78, 0x0a, 0x20, 0x43, 0x6f, 0x6d, 0x70, 0x75, 0x74, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, - 0x6e, 0x63, 0x65, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x52, 0x65, 0x66, 0x65, 0x72, - 0x65, 0x6e, 0x63, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x70, 0x72, 0x6f, 0x6a, - 0x65, 0x63, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, - 0x63, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x68, 0x61, 0x72, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, - 0x28, 0x08, 0x52, 0x06, 0x73, 0x68, 0x61, 0x72, 0x65, 0x64, 0x22, 0x7b, 0x0a, 0x23, 0x43, 0x6f, - 0x6d, 0x70, 0x75, 0x74, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x43, 0x61, 0x74, - 0x61, 0x6c, 0x6f, 0x67, 0x49, 0x74, 0x65, 0x6d, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, + 0x74, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x44, 0x69, 0x73, 0x6b, 0x52, 0x0f, + 0x61, 0x64, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x44, 0x69, 0x73, 0x6b, 0x73, 0x12, + 0x26, 0x0a, 0x0c, 0x72, 0x75, 0x6e, 0x5f, 0x73, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x18, + 0x0a, 0x20, 0x01, 0x28, 0x09, 0x48, 0x04, 0x52, 0x0b, 0x72, 0x75, 0x6e, 0x53, 0x74, 0x72, 0x61, + 0x74, 0x65, 0x67, 0x79, 0x88, 0x01, 0x01, 0x12, 0x20, 0x0a, 0x09, 0x75, 0x73, 0x65, 0x72, 0x5f, + 0x64, 0x61, 0x74, 0x61, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x48, 0x05, 0x52, 0x08, 0x75, 0x73, + 0x65, 0x72, 0x44, 0x61, 0x74, 0x61, 0x88, 0x01, 0x01, 0x12, 0x53, 0x0a, 0x13, 0x6e, 0x65, 0x74, + 0x77, 0x6f, 0x72, 0x6b, 0x5f, 0x61, 0x74, 0x74, 0x61, 0x63, 0x68, 0x6d, 0x65, 0x6e, 0x74, 0x73, + 0x18, 0x0e, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, + 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, + 0x41, 0x74, 0x74, 0x61, 0x63, 0x68, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x12, 0x6e, 0x65, 0x74, 0x77, + 0x6f, 0x72, 0x6b, 0x41, 0x74, 0x74, 0x61, 0x63, 0x68, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x57, + 0x0a, 0x0c, 0x63, 0x61, 0x74, 0x61, 0x6c, 0x6f, 0x67, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x18, 0x0f, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x34, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, + 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x75, 0x74, 0x65, 0x49, 0x6e, + 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x43, 0x61, 0x74, 0x61, 0x6c, 0x6f, 0x67, 0x49, 0x74, 0x65, + 0x6d, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x0b, 0x63, 0x61, 0x74, 0x61, + 0x6c, 0x6f, 0x67, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x22, 0x0a, 0x0a, 0x69, 0x73, 0x5f, 0x77, 0x69, + 0x6e, 0x64, 0x6f, 0x77, 0x73, 0x18, 0x10, 0x20, 0x01, 0x28, 0x08, 0x48, 0x06, 0x52, 0x09, 0x69, + 0x73, 0x57, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x73, 0x88, 0x01, 0x01, 0x12, 0x4b, 0x0a, 0x0d, 0x69, + 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x11, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, + 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x54, 0x79, 0x70, + 0x65, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x0c, 0x69, 0x6e, 0x73, 0x74, + 0x61, 0x6e, 0x63, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x42, 0x0a, 0x1b, 0x61, 0x75, 0x74, 0x6f, + 0x5f, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x5f, 0x69, 0x70, 0x5f, 0x61, 0x74, 0x74, + 0x61, 0x63, 0x68, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x12, 0x20, 0x01, 0x28, 0x08, 0x42, 0x03, 0xe0, + 0x41, 0x05, 0x52, 0x18, 0x61, 0x75, 0x74, 0x6f, 0x45, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, + 0x49, 0x70, 0x41, 0x74, 0x74, 0x61, 0x63, 0x68, 0x6d, 0x65, 0x6e, 0x74, 0x1a, 0x5b, 0x0a, 0x17, + 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, + 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2a, 0x0a, 0x05, 0x76, 0x61, 0x6c, + 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, + 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, + 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, 0x17, 0x0a, 0x15, 0x5f, 0x72, 0x65, + 0x73, 0x74, 0x61, 0x72, 0x74, 0x5f, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x65, 0x64, 0x5f, + 0x61, 0x74, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x42, 0x11, 0x0a, 0x0f, + 0x5f, 0x73, 0x73, 0x68, 0x5f, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x42, + 0x0c, 0x0a, 0x0a, 0x5f, 0x62, 0x6f, 0x6f, 0x74, 0x5f, 0x64, 0x69, 0x73, 0x6b, 0x42, 0x0f, 0x0a, + 0x0d, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x73, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x42, 0x0c, + 0x0a, 0x0a, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x64, 0x61, 0x74, 0x61, 0x42, 0x0d, 0x0a, 0x0b, + 0x5f, 0x69, 0x73, 0x5f, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x73, 0x4a, 0x04, 0x08, 0x05, 0x10, + 0x06, 0x4a, 0x04, 0x08, 0x06, 0x10, 0x07, 0x4a, 0x04, 0x08, 0x0c, 0x10, 0x0d, 0x4a, 0x04, 0x08, + 0x0d, 0x10, 0x0e, 0x52, 0x05, 0x63, 0x6f, 0x72, 0x65, 0x73, 0x52, 0x0a, 0x6d, 0x65, 0x6d, 0x6f, + 0x72, 0x79, 0x5f, 0x67, 0x69, 0x62, 0x52, 0x06, 0x73, 0x75, 0x62, 0x6e, 0x65, 0x74, 0x52, 0x0f, + 0x73, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x22, + 0xe3, 0x03, 0x0a, 0x15, 0x43, 0x6f, 0x6d, 0x70, 0x75, 0x74, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, + 0x6e, 0x63, 0x65, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x3b, 0x0a, 0x05, 0x73, 0x74, 0x61, + 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x25, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, + 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x75, + 0x74, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, + 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x49, 0x0a, 0x0a, 0x63, 0x6f, 0x6e, 0x64, 0x69, 0x74, + 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x6f, 0x73, 0x61, + 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6d, + 0x70, 0x75, 0x74, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x64, + 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x0a, 0x63, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, + 0x73, 0x12, 0x2e, 0x0a, 0x13, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x5f, 0x69, 0x70, + 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x11, + 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x49, 0x70, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, + 0x73, 0x12, 0x10, 0x0a, 0x03, 0x68, 0x75, 0x62, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, + 0x68, 0x75, 0x62, 0x12, 0x4b, 0x0a, 0x11, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x72, 0x65, 0x73, 0x74, + 0x61, 0x72, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, + 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, + 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x48, 0x00, 0x52, 0x0f, 0x6c, 0x61, + 0x73, 0x74, 0x52, 0x65, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x41, 0x74, 0x88, 0x01, 0x01, + 0x12, 0x2e, 0x0a, 0x13, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x5f, 0x69, 0x70, 0x5f, + 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x11, 0x65, + 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x49, 0x70, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, + 0x12, 0x53, 0x0a, 0x15, 0x73, 0x74, 0x61, 0x74, 0x65, 0x5f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x69, + 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, + 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x48, 0x01, 0x52, 0x13, 0x73, + 0x74, 0x61, 0x74, 0x65, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x69, + 0x6d, 0x65, 0x88, 0x01, 0x01, 0x42, 0x14, 0x0a, 0x12, 0x5f, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x72, + 0x65, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x42, 0x18, 0x0a, 0x16, 0x5f, + 0x73, 0x74, 0x61, 0x74, 0x65, 0x5f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, + 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x22, 0xb8, 0x02, 0x0a, 0x18, 0x43, 0x6f, 0x6d, 0x70, 0x75, 0x74, + 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, + 0x6f, 0x6e, 0x12, 0x41, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, + 0x32, 0x2d, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, + 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x75, 0x74, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, + 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x52, + 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x38, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x20, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, + 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, + 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, + 0x4c, 0x0a, 0x14, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, + 0x6f, 0x6e, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, + 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, + 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x12, 0x6c, 0x61, 0x73, 0x74, 0x54, + 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x1b, 0x0a, + 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, + 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x6d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x07, 0x6d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x88, 0x01, 0x01, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x72, 0x65, + 0x61, 0x73, 0x6f, 0x6e, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x22, 0x43, 0x0a, 0x1d, 0x43, 0x6f, 0x6d, 0x70, 0x75, 0x74, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, + 0x6e, 0x63, 0x65, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x12, - 0x16, 0x0a, 0x06, 0x73, 0x68, 0x61, 0x72, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x06, 0x73, 0x68, 0x61, 0x72, 0x65, 0x64, 0x2a, 0xbb, 0x02, 0x0a, 0x14, 0x43, 0x6f, 0x6d, 0x70, - 0x75, 0x74, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x53, 0x74, 0x61, 0x74, 0x65, - 0x12, 0x26, 0x0a, 0x22, 0x43, 0x4f, 0x4d, 0x50, 0x55, 0x54, 0x45, 0x5f, 0x49, 0x4e, 0x53, 0x54, - 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, - 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x23, 0x0a, 0x1f, 0x43, 0x4f, 0x4d, 0x50, - 0x55, 0x54, 0x45, 0x5f, 0x49, 0x4e, 0x53, 0x54, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x53, 0x54, 0x41, - 0x54, 0x45, 0x5f, 0x53, 0x54, 0x41, 0x52, 0x54, 0x49, 0x4e, 0x47, 0x10, 0x01, 0x12, 0x22, 0x0a, - 0x1e, 0x43, 0x4f, 0x4d, 0x50, 0x55, 0x54, 0x45, 0x5f, 0x49, 0x4e, 0x53, 0x54, 0x41, 0x4e, 0x43, - 0x45, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x52, 0x55, 0x4e, 0x4e, 0x49, 0x4e, 0x47, 0x10, - 0x02, 0x12, 0x21, 0x0a, 0x1d, 0x43, 0x4f, 0x4d, 0x50, 0x55, 0x54, 0x45, 0x5f, 0x49, 0x4e, 0x53, - 0x54, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x46, 0x41, 0x49, 0x4c, - 0x45, 0x44, 0x10, 0x03, 0x12, 0x23, 0x0a, 0x1f, 0x43, 0x4f, 0x4d, 0x50, 0x55, 0x54, 0x45, 0x5f, - 0x49, 0x4e, 0x53, 0x54, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x44, - 0x45, 0x4c, 0x45, 0x54, 0x49, 0x4e, 0x47, 0x10, 0x04, 0x12, 0x23, 0x0a, 0x1f, 0x43, 0x4f, 0x4d, + 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x78, 0x0a, 0x20, 0x43, 0x6f, 0x6d, 0x70, 0x75, 0x74, 0x65, + 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, + 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, + 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, + 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, + 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x68, 0x61, 0x72, 0x65, + 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x73, 0x68, 0x61, 0x72, 0x65, 0x64, 0x22, + 0x7b, 0x0a, 0x23, 0x43, 0x6f, 0x6d, 0x70, 0x75, 0x74, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, + 0x63, 0x65, 0x43, 0x61, 0x74, 0x61, 0x6c, 0x6f, 0x67, 0x49, 0x74, 0x65, 0x6d, 0x52, 0x65, 0x66, + 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x70, 0x72, + 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x70, 0x72, 0x6f, + 0x6a, 0x65, 0x63, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x68, 0x61, 0x72, 0x65, 0x64, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x73, 0x68, 0x61, 0x72, 0x65, 0x64, 0x2a, 0xbb, 0x02, 0x0a, + 0x14, 0x43, 0x6f, 0x6d, 0x70, 0x75, 0x74, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, + 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x26, 0x0a, 0x22, 0x43, 0x4f, 0x4d, 0x50, 0x55, 0x54, 0x45, + 0x5f, 0x49, 0x4e, 0x53, 0x54, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, + 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x23, 0x0a, + 0x1f, 0x43, 0x4f, 0x4d, 0x50, 0x55, 0x54, 0x45, 0x5f, 0x49, 0x4e, 0x53, 0x54, 0x41, 0x4e, 0x43, + 0x45, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x53, 0x54, 0x41, 0x52, 0x54, 0x49, 0x4e, 0x47, + 0x10, 0x01, 0x12, 0x22, 0x0a, 0x1e, 0x43, 0x4f, 0x4d, 0x50, 0x55, 0x54, 0x45, 0x5f, 0x49, 0x4e, + 0x53, 0x54, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x52, 0x55, 0x4e, + 0x4e, 0x49, 0x4e, 0x47, 0x10, 0x02, 0x12, 0x21, 0x0a, 0x1d, 0x43, 0x4f, 0x4d, 0x50, 0x55, 0x54, + 0x45, 0x5f, 0x49, 0x4e, 0x53, 0x54, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, + 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x03, 0x12, 0x23, 0x0a, 0x1f, 0x43, 0x4f, 0x4d, 0x50, 0x55, 0x54, 0x45, 0x5f, 0x49, 0x4e, 0x53, 0x54, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x53, 0x54, - 0x41, 0x54, 0x45, 0x5f, 0x53, 0x54, 0x4f, 0x50, 0x50, 0x49, 0x4e, 0x47, 0x10, 0x05, 0x12, 0x22, - 0x0a, 0x1e, 0x43, 0x4f, 0x4d, 0x50, 0x55, 0x54, 0x45, 0x5f, 0x49, 0x4e, 0x53, 0x54, 0x41, 0x4e, - 0x43, 0x45, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x53, 0x54, 0x4f, 0x50, 0x50, 0x45, 0x44, - 0x10, 0x06, 0x12, 0x21, 0x0a, 0x1d, 0x43, 0x4f, 0x4d, 0x50, 0x55, 0x54, 0x45, 0x5f, 0x49, 0x4e, - 0x53, 0x54, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x50, 0x41, 0x55, - 0x53, 0x45, 0x44, 0x10, 0x07, 0x2a, 0x89, 0x03, 0x0a, 0x1c, 0x43, 0x6f, 0x6d, 0x70, 0x75, 0x74, - 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, - 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x12, 0x2f, 0x0a, 0x2b, 0x43, 0x4f, 0x4d, 0x50, 0x55, 0x54, - 0x45, 0x5f, 0x49, 0x4e, 0x53, 0x54, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x43, 0x4f, 0x4e, 0x44, 0x49, - 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, - 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x39, 0x0a, 0x35, 0x43, 0x4f, 0x4d, 0x50, 0x55, + 0x41, 0x54, 0x45, 0x5f, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x49, 0x4e, 0x47, 0x10, 0x04, 0x12, 0x23, + 0x0a, 0x1f, 0x43, 0x4f, 0x4d, 0x50, 0x55, 0x54, 0x45, 0x5f, 0x49, 0x4e, 0x53, 0x54, 0x41, 0x4e, + 0x43, 0x45, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x53, 0x54, 0x4f, 0x50, 0x50, 0x49, 0x4e, + 0x47, 0x10, 0x05, 0x12, 0x22, 0x0a, 0x1e, 0x43, 0x4f, 0x4d, 0x50, 0x55, 0x54, 0x45, 0x5f, 0x49, + 0x4e, 0x53, 0x54, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x53, 0x54, + 0x4f, 0x50, 0x50, 0x45, 0x44, 0x10, 0x06, 0x12, 0x21, 0x0a, 0x1d, 0x43, 0x4f, 0x4d, 0x50, 0x55, + 0x54, 0x45, 0x5f, 0x49, 0x4e, 0x53, 0x54, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x53, 0x54, 0x41, 0x54, + 0x45, 0x5f, 0x50, 0x41, 0x55, 0x53, 0x45, 0x44, 0x10, 0x07, 0x2a, 0x89, 0x03, 0x0a, 0x1c, 0x43, + 0x6f, 0x6d, 0x70, 0x75, 0x74, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x43, 0x6f, + 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x12, 0x2f, 0x0a, 0x2b, 0x43, + 0x4f, 0x4d, 0x50, 0x55, 0x54, 0x45, 0x5f, 0x49, 0x4e, 0x53, 0x54, 0x41, 0x4e, 0x43, 0x45, 0x5f, + 0x43, 0x4f, 0x4e, 0x44, 0x49, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x55, + 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x39, 0x0a, 0x35, + 0x43, 0x4f, 0x4d, 0x50, 0x55, 0x54, 0x45, 0x5f, 0x49, 0x4e, 0x53, 0x54, 0x41, 0x4e, 0x43, 0x45, + 0x5f, 0x43, 0x4f, 0x4e, 0x44, 0x49, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, + 0x43, 0x4f, 0x4e, 0x46, 0x49, 0x47, 0x55, 0x52, 0x41, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x41, 0x50, + 0x50, 0x4c, 0x49, 0x45, 0x44, 0x10, 0x01, 0x12, 0x29, 0x0a, 0x25, 0x43, 0x4f, 0x4d, 0x50, 0x55, 0x54, 0x45, 0x5f, 0x49, 0x4e, 0x53, 0x54, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x43, 0x4f, 0x4e, 0x44, - 0x49, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x43, 0x4f, 0x4e, 0x46, 0x49, - 0x47, 0x55, 0x52, 0x41, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x41, 0x50, 0x50, 0x4c, 0x49, 0x45, 0x44, - 0x10, 0x01, 0x12, 0x29, 0x0a, 0x25, 0x43, 0x4f, 0x4d, 0x50, 0x55, 0x54, 0x45, 0x5f, 0x49, 0x4e, + 0x49, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x52, 0x45, 0x41, 0x44, 0x59, + 0x10, 0x02, 0x12, 0x37, 0x0a, 0x33, 0x43, 0x4f, 0x4d, 0x50, 0x55, 0x54, 0x45, 0x5f, 0x49, 0x4e, 0x53, 0x54, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x43, 0x4f, 0x4e, 0x44, 0x49, 0x54, 0x49, 0x4f, 0x4e, - 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x52, 0x45, 0x41, 0x44, 0x59, 0x10, 0x02, 0x12, 0x37, 0x0a, - 0x33, 0x43, 0x4f, 0x4d, 0x50, 0x55, 0x54, 0x45, 0x5f, 0x49, 0x4e, 0x53, 0x54, 0x41, 0x4e, 0x43, - 0x45, 0x5f, 0x43, 0x4f, 0x4e, 0x44, 0x49, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59, 0x50, 0x45, - 0x5f, 0x52, 0x45, 0x53, 0x54, 0x41, 0x52, 0x54, 0x5f, 0x49, 0x4e, 0x5f, 0x50, 0x52, 0x4f, 0x47, - 0x52, 0x45, 0x53, 0x53, 0x10, 0x03, 0x12, 0x32, 0x0a, 0x2e, 0x43, 0x4f, 0x4d, 0x50, 0x55, 0x54, - 0x45, 0x5f, 0x49, 0x4e, 0x53, 0x54, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x43, 0x4f, 0x4e, 0x44, 0x49, - 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x52, 0x45, 0x53, 0x54, 0x41, 0x52, - 0x54, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x04, 0x12, 0x2f, 0x0a, 0x2b, 0x43, 0x4f, - 0x4d, 0x50, 0x55, 0x54, 0x45, 0x5f, 0x49, 0x4e, 0x53, 0x54, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x43, - 0x4f, 0x4e, 0x44, 0x49, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x50, 0x52, - 0x4f, 0x56, 0x49, 0x53, 0x49, 0x4f, 0x4e, 0x45, 0x44, 0x10, 0x05, 0x12, 0x34, 0x0a, 0x30, 0x43, + 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x52, 0x45, 0x53, 0x54, 0x41, 0x52, 0x54, 0x5f, 0x49, 0x4e, + 0x5f, 0x50, 0x52, 0x4f, 0x47, 0x52, 0x45, 0x53, 0x53, 0x10, 0x03, 0x12, 0x32, 0x0a, 0x2e, 0x43, 0x4f, 0x4d, 0x50, 0x55, 0x54, 0x45, 0x5f, 0x49, 0x4e, 0x53, 0x54, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x43, 0x4f, 0x4e, 0x44, 0x49, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x52, - 0x45, 0x53, 0x54, 0x41, 0x52, 0x54, 0x5f, 0x52, 0x45, 0x51, 0x55, 0x49, 0x52, 0x45, 0x44, 0x10, - 0x06, 0x42, 0xdd, 0x01, 0x0a, 0x13, 0x63, 0x6f, 0x6d, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, - 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x42, 0x18, 0x43, 0x6f, 0x6d, 0x70, 0x75, - 0x74, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x54, 0x79, 0x70, 0x65, 0x50, 0x72, - 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x4c, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, - 0x6d, 0x2f, 0x6f, 0x73, 0x61, 0x63, 0x2d, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x2f, 0x6f, - 0x73, 0x61, 0x63, 0x2d, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x69, 0x6e, 0x67, 0x2f, 0x69, 0x6e, 0x74, - 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x6f, 0x73, 0x61, 0x63, 0x2f, 0x70, - 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x31, 0x3b, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, - 0x65, 0x76, 0x31, 0xa2, 0x02, 0x03, 0x4f, 0x50, 0x58, 0xaa, 0x02, 0x0f, 0x4f, 0x73, 0x61, 0x63, - 0x2e, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x56, 0x31, 0xca, 0x02, 0x10, 0x4f, 0x73, - 0x61, 0x63, 0x5c, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, 0x5c, 0x56, 0x31, 0xe2, 0x02, - 0x1c, 0x4f, 0x73, 0x61, 0x63, 0x5c, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, 0x5c, 0x56, - 0x31, 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0xea, 0x02, 0x11, - 0x4f, 0x73, 0x61, 0x63, 0x3a, 0x3a, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x3a, 0x3a, 0x56, - 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x45, 0x53, 0x54, 0x41, 0x52, 0x54, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x04, 0x12, + 0x2f, 0x0a, 0x2b, 0x43, 0x4f, 0x4d, 0x50, 0x55, 0x54, 0x45, 0x5f, 0x49, 0x4e, 0x53, 0x54, 0x41, + 0x4e, 0x43, 0x45, 0x5f, 0x43, 0x4f, 0x4e, 0x44, 0x49, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59, + 0x50, 0x45, 0x5f, 0x50, 0x52, 0x4f, 0x56, 0x49, 0x53, 0x49, 0x4f, 0x4e, 0x45, 0x44, 0x10, 0x05, + 0x12, 0x34, 0x0a, 0x30, 0x43, 0x4f, 0x4d, 0x50, 0x55, 0x54, 0x45, 0x5f, 0x49, 0x4e, 0x53, 0x54, + 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x43, 0x4f, 0x4e, 0x44, 0x49, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, + 0x59, 0x50, 0x45, 0x5f, 0x52, 0x45, 0x53, 0x54, 0x41, 0x52, 0x54, 0x5f, 0x52, 0x45, 0x51, 0x55, + 0x49, 0x52, 0x45, 0x44, 0x10, 0x06, 0x42, 0xdd, 0x01, 0x0a, 0x13, 0x63, 0x6f, 0x6d, 0x2e, 0x6f, + 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x42, 0x18, + 0x43, 0x6f, 0x6d, 0x70, 0x75, 0x74, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x54, + 0x79, 0x70, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x4c, 0x67, 0x69, 0x74, 0x68, + 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6f, 0x73, 0x61, 0x63, 0x2d, 0x70, 0x72, 0x6f, 0x6a, + 0x65, 0x63, 0x74, 0x2f, 0x6f, 0x73, 0x61, 0x63, 0x2d, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x69, 0x6e, + 0x67, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x6f, + 0x73, 0x61, 0x63, 0x2f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x31, 0x3b, 0x70, + 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x76, 0x31, 0xa2, 0x02, 0x03, 0x4f, 0x50, 0x58, 0xaa, 0x02, + 0x0f, 0x4f, 0x73, 0x61, 0x63, 0x2e, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x56, 0x31, + 0xca, 0x02, 0x10, 0x4f, 0x73, 0x61, 0x63, 0x5c, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, + 0x5c, 0x56, 0x31, 0xe2, 0x02, 0x1c, 0x4f, 0x73, 0x61, 0x63, 0x5c, 0x50, 0x72, 0x69, 0x76, 0x61, + 0x74, 0x65, 0x5f, 0x5c, 0x56, 0x31, 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, + 0x74, 0x61, 0xea, 0x02, 0x11, 0x4f, 0x73, 0x61, 0x63, 0x3a, 0x3a, 0x50, 0x72, 0x69, 0x76, 0x61, + 0x74, 0x65, 0x3a, 0x3a, 0x56, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, }) var ( diff --git a/osac-metering/metering-service/internal/api/osac/private/v1/metadata_type.pb.go b/osac-metering/metering-service/internal/api/osac/private/v1/metadata_type.pb.go index a5cd29aa4..498c63cc4 100644 --- a/osac-metering/metering-service/internal/api/osac/private/v1/metadata_type.pb.go +++ b/osac-metering/metering-service/internal/api/osac/private/v1/metadata_type.pb.go @@ -50,15 +50,13 @@ type Metadata struct { Creator string `protobuf:"bytes,4,opt,name=creator,proto3" json:"creator,omitempty"` // Tenant contains the identifier of the tenant that the object belongs to. Tenant string `protobuf:"bytes,5,opt,name=tenant,proto3" json:"tenant,omitempty"` - // Human friendly name of the object. + // Mandatory, immutable, and unique within scope. Must be a valid RFC 1123 DNS label. // - // Has the same restrictions than DNS labels, as described in RFC 1035: + // Has the same restrictions as DNS labels, as described in RFC 1123: // // - Must be between 1 and 63 characters long. - // - Must only contain letters (a-z), digits (0-9) and hyphens (-). - // - It isn't case sensitive. - // - // It is optional and not unique, so multiple objecs, even created by the same user or tenant, can have the same name. + // - Must only contain lowercase letters (a-z), digits (0-9) and hyphens (-). + // - Must start and end with an alphanumeric character. Name string `protobuf:"bytes,6,opt,name=name,proto3" json:"name,omitempty"` // Labels contains key-value pairs for organizing and selecting objects. // @@ -212,7 +210,7 @@ var file_osac_private_v1_metadata_type_proto_rawDesc = string([]byte{ 0x64, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xf4, 0x04, 0x0a, 0x08, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, + 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xf3, 0x04, 0x0a, 0x08, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x49, 0x0a, 0x12, 0x63, 0x72, 0x65, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, @@ -227,45 +225,45 @@ var file_osac_private_v1_metadata_type_proto_rawDesc = string([]byte{ 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x72, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x72, 0x65, 0x61, 0x74, 0x6f, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x72, 0x65, 0x61, 0x74, 0x6f, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x06, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x12, 0x44, 0x0a, 0x04, 0x6e, 0x61, 0x6d, - 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x42, 0x30, 0xba, 0x48, 0x2d, 0x72, 0x2b, 0x18, 0x3f, - 0x32, 0x27, 0x5e, 0x28, 0x5b, 0x61, 0x2d, 0x7a, 0x30, 0x2d, 0x39, 0x5d, 0x28, 0x5b, 0x61, 0x2d, - 0x7a, 0x30, 0x2d, 0x39, 0x2d, 0x5d, 0x7b, 0x30, 0x2c, 0x36, 0x31, 0x7d, 0x5b, 0x61, 0x2d, 0x7a, - 0x30, 0x2d, 0x39, 0x5d, 0x29, 0x3f, 0x29, 0x3f, 0x24, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, - 0x3d, 0x0a, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, - 0x25, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, - 0x31, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x2e, 0x4c, 0x61, 0x62, 0x65, 0x6c, - 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, 0x4c, - 0x0a, 0x0b, 0x61, 0x6e, 0x6e, 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x08, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x2a, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, - 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x2e, 0x41, - 0x6e, 0x6e, 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, - 0x0b, 0x61, 0x6e, 0x6e, 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x18, 0x0a, 0x07, - 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x05, 0x52, 0x07, 0x76, - 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x18, 0x0a, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, - 0x74, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, - 0x1a, 0x39, 0x0a, 0x0b, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, - 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, - 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x3e, 0x0a, 0x10, 0x41, - 0x6e, 0x6e, 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, - 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, - 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, 0xd6, 0x01, 0x0a, 0x13, - 0x63, 0x6f, 0x6d, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, - 0x2e, 0x76, 0x31, 0x42, 0x11, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x54, 0x79, 0x70, - 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x4c, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, - 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6f, 0x73, 0x61, 0x63, 0x2d, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, - 0x74, 0x2f, 0x6f, 0x73, 0x61, 0x63, 0x2d, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x69, 0x6e, 0x67, 0x2f, - 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x6f, 0x73, 0x61, - 0x63, 0x2f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x31, 0x3b, 0x70, 0x72, 0x69, - 0x76, 0x61, 0x74, 0x65, 0x76, 0x31, 0xa2, 0x02, 0x03, 0x4f, 0x50, 0x58, 0xaa, 0x02, 0x0f, 0x4f, - 0x73, 0x61, 0x63, 0x2e, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x56, 0x31, 0xca, 0x02, - 0x10, 0x4f, 0x73, 0x61, 0x63, 0x5c, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, 0x5c, 0x56, - 0x31, 0xe2, 0x02, 0x1c, 0x4f, 0x73, 0x61, 0x63, 0x5c, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, - 0x5f, 0x5c, 0x56, 0x31, 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, - 0xea, 0x02, 0x11, 0x4f, 0x73, 0x61, 0x63, 0x3a, 0x3a, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, - 0x3a, 0x3a, 0x56, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x09, 0x52, 0x06, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x12, 0x43, 0x0a, 0x04, 0x6e, 0x61, 0x6d, + 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x42, 0x2f, 0xba, 0x48, 0x2c, 0x72, 0x2a, 0x10, 0x01, + 0x18, 0x3f, 0x32, 0x24, 0x5e, 0x5b, 0x61, 0x2d, 0x7a, 0x30, 0x2d, 0x39, 0x5d, 0x28, 0x5b, 0x61, + 0x2d, 0x7a, 0x30, 0x2d, 0x39, 0x2d, 0x5d, 0x7b, 0x30, 0x2c, 0x36, 0x31, 0x7d, 0x5b, 0x61, 0x2d, + 0x7a, 0x30, 0x2d, 0x39, 0x5d, 0x29, 0x3f, 0x24, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x3d, + 0x0a, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x25, + 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, + 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x2e, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, + 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, 0x4c, 0x0a, + 0x0b, 0x61, 0x6e, 0x6e, 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x2a, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, + 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x2e, 0x41, 0x6e, + 0x6e, 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0b, + 0x61, 0x6e, 0x6e, 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x76, + 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x05, 0x52, 0x07, 0x76, 0x65, + 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x18, 0x0a, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, + 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x1a, + 0x39, 0x0a, 0x0b, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, + 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, + 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x3e, 0x0a, 0x10, 0x41, 0x6e, + 0x6e, 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, + 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, + 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, 0xd6, 0x01, 0x0a, 0x13, 0x63, + 0x6f, 0x6d, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, + 0x76, 0x31, 0x42, 0x11, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x54, 0x79, 0x70, 0x65, + 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x4c, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, + 0x63, 0x6f, 0x6d, 0x2f, 0x6f, 0x73, 0x61, 0x63, 0x2d, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, + 0x2f, 0x6f, 0x73, 0x61, 0x63, 0x2d, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x69, 0x6e, 0x67, 0x2f, 0x69, + 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x6f, 0x73, 0x61, 0x63, + 0x2f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x31, 0x3b, 0x70, 0x72, 0x69, 0x76, + 0x61, 0x74, 0x65, 0x76, 0x31, 0xa2, 0x02, 0x03, 0x4f, 0x50, 0x58, 0xaa, 0x02, 0x0f, 0x4f, 0x73, + 0x61, 0x63, 0x2e, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x56, 0x31, 0xca, 0x02, 0x10, + 0x4f, 0x73, 0x61, 0x63, 0x5c, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, 0x5c, 0x56, 0x31, + 0xe2, 0x02, 0x1c, 0x4f, 0x73, 0x61, 0x63, 0x5c, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, + 0x5c, 0x56, 0x31, 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0xea, + 0x02, 0x11, 0x4f, 0x73, 0x61, 0x63, 0x3a, 0x3a, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x3a, + 0x3a, 0x56, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, }) var ( diff --git a/osac-metering/metering-service/internal/api/osac/private/v1/network_class_type.pb.go b/osac-metering/metering-service/internal/api/osac/private/v1/network_class_type.pb.go index 52e4be6a4..ec730aa85 100644 --- a/osac-metering/metering-service/internal/api/osac/private/v1/network_class_type.pb.go +++ b/osac-metering/metering-service/internal/api/osac/private/v1/network_class_type.pb.go @@ -137,11 +137,13 @@ type NetworkClass struct { IsDefault *bool `protobuf:"varint,9,opt,name=is_default,json=isDefault,proto3,oneof" json:"is_default,omitempty"` // Identifier of the fabric manager that handles physical network operations for this class. The fabric manager // controls all fabric-level operations: virtual network segments, security groups, external IPs, and NAT gateways. - // For example: "netris", "neutron". - FabricManager string `protobuf:"bytes,10,opt,name=fabric_manager,json=fabricManager,proto3" json:"fabric_manager,omitempty"` + // For example: "netris", "neutron". Not needed for k8s-only deployments — if unset, k8s_manager must be set + // instead, and k8s-only operations (VirtualNetwork, Subnet, SecurityGroup, ExternalIP) are routed to it. + FabricManager *string `protobuf:"bytes,10,opt,name=fabric_manager,json=fabricManager,proto3,oneof" json:"fabric_manager,omitempty"` // Identifier of the K8s manager that bridges the Kubernetes overlay to the physical fabric. Only needed for regions // that host VMs — the K8s manager creates the overlay network on hosting clusters and bridges it to the fabric - // segment. For example: "cudn_localnet". If not set, the region does not support VM workloads. + // segment. For example: "cudn_localnet". If not set, the region does not support VM workloads. At least one of + // fabric_manager or k8s_manager must be set. K8SManager *string `protobuf:"bytes,11,opt,name=k8s_manager,json=k8sManager,proto3,oneof" json:"k8s_manager,omitempty"` // Desired configuration for this network class. Spec *NetworkClassSpec `protobuf:"bytes,12,opt,name=spec,proto3" json:"spec,omitempty"` @@ -243,8 +245,8 @@ func (x *NetworkClass) GetIsDefault() bool { } func (x *NetworkClass) GetFabricManager() string { - if x != nil { - return x.FabricManager + if x != nil && x.FabricManager != nil { + return *x.FabricManager } return "" } @@ -639,7 +641,7 @@ var file_osac_private_v1_network_class_type_proto_rawDesc = string([]byte{ 0x74, 0x61, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x29, 0x6f, 0x73, 0x61, 0x63, 0x2f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x31, 0x2f, 0x73, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x74, 0x79, - 0x70, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xe5, 0x04, 0x0a, 0x0c, 0x4e, 0x65, 0x74, + 0x70, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xfd, 0x04, 0x0a, 0x0c, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x35, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6f, 0x73, @@ -667,158 +669,160 @@ var file_osac_private_v1_network_class_type_proto_rawDesc = string([]byte{ 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x22, 0x0a, 0x0a, 0x69, 0x73, 0x5f, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x48, 0x00, 0x52, - 0x09, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x88, 0x01, 0x01, 0x12, 0x25, 0x0a, + 0x09, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x88, 0x01, 0x01, 0x12, 0x2a, 0x0a, 0x0e, 0x66, 0x61, 0x62, 0x72, 0x69, 0x63, 0x5f, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x18, - 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x66, 0x61, 0x62, 0x72, 0x69, 0x63, 0x4d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x72, 0x12, 0x24, 0x0a, 0x0b, 0x6b, 0x38, 0x73, 0x5f, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x72, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x0a, 0x6b, 0x38, 0x73, - 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x88, 0x01, 0x01, 0x12, 0x35, 0x0a, 0x04, 0x73, 0x70, - 0x65, 0x63, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, - 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, - 0x72, 0x6b, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x53, 0x70, 0x65, 0x63, 0x52, 0x04, 0x73, 0x70, 0x65, - 0x63, 0x42, 0x0d, 0x0a, 0x0b, 0x5f, 0x69, 0x73, 0x5f, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, - 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x6b, 0x38, 0x73, 0x5f, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, - 0x22, 0x81, 0x02, 0x0a, 0x10, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x43, 0x6c, 0x61, 0x73, - 0x73, 0x53, 0x70, 0x65, 0x63, 0x12, 0x3c, 0x0a, 0x08, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, - 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, - 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, - 0x6b, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x73, 0x52, 0x08, 0x64, 0x65, 0x66, 0x61, 0x75, - 0x6c, 0x74, 0x73, 0x12, 0x5c, 0x0a, 0x14, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x5f, 0x63, - 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x29, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, - 0x2e, 0x76, 0x31, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x43, 0x6c, 0x61, 0x73, 0x73, - 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x52, 0x13, 0x64, 0x69, - 0x73, 0x61, 0x62, 0x6c, 0x65, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, - 0x73, 0x12, 0x3b, 0x0a, 0x11, 0x76, 0x69, 0x70, 0x5f, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x5f, - 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x42, 0x0a, 0xba, 0x48, - 0x07, 0x1a, 0x05, 0x18, 0x80, 0x01, 0x28, 0x01, 0x48, 0x00, 0x52, 0x0f, 0x76, 0x69, 0x70, 0x50, - 0x72, 0x65, 0x66, 0x69, 0x78, 0x4c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x88, 0x01, 0x01, 0x42, 0x14, - 0x0a, 0x12, 0x5f, 0x76, 0x69, 0x70, 0x5f, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x5f, 0x6c, 0x65, - 0x6e, 0x67, 0x74, 0x68, 0x22, 0xe4, 0x09, 0x0a, 0x0f, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, - 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x73, 0x12, 0xb2, 0x01, 0x0a, 0x19, 0x76, 0x69, 0x72, - 0x74, 0x75, 0x61, 0x6c, 0x5f, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x5f, 0x69, 0x70, 0x76, - 0x34, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x77, 0xba, 0x48, - 0x74, 0xba, 0x01, 0x71, 0x0a, 0x0f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x5f, 0x69, 0x70, 0x76, 0x34, - 0x5f, 0x63, 0x69, 0x64, 0x72, 0x12, 0x36, 0x6d, 0x75, 0x73, 0x74, 0x20, 0x62, 0x65, 0x20, 0x76, - 0x61, 0x6c, 0x69, 0x64, 0x20, 0x49, 0x50, 0x76, 0x34, 0x20, 0x43, 0x49, 0x44, 0x52, 0x20, 0x6e, - 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x20, 0x28, 0x65, 0x2e, 0x67, 0x2e, 0x2c, 0x20, 0x27, - 0x31, 0x30, 0x2e, 0x30, 0x2e, 0x30, 0x2e, 0x30, 0x2f, 0x31, 0x36, 0x27, 0x29, 0x1a, 0x26, 0x74, - 0x68, 0x69, 0x73, 0x20, 0x3d, 0x3d, 0x20, 0x27, 0x27, 0x20, 0x7c, 0x7c, 0x20, 0x74, 0x68, 0x69, - 0x73, 0x2e, 0x69, 0x73, 0x49, 0x70, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x28, 0x34, 0x2c, 0x20, - 0x74, 0x72, 0x75, 0x65, 0x29, 0x52, 0x16, 0x76, 0x69, 0x72, 0x74, 0x75, 0x61, 0x6c, 0x4e, 0x65, - 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x49, 0x70, 0x76, 0x34, 0x43, 0x69, 0x64, 0x72, 0x12, 0xb0, 0x01, - 0x0a, 0x19, 0x76, 0x69, 0x72, 0x74, 0x75, 0x61, 0x6c, 0x5f, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, - 0x6b, 0x5f, 0x69, 0x70, 0x76, 0x36, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, - 0x09, 0x42, 0x75, 0xba, 0x48, 0x72, 0xba, 0x01, 0x6f, 0x0a, 0x0f, 0x76, 0x61, 0x6c, 0x69, 0x64, - 0x5f, 0x69, 0x70, 0x76, 0x36, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x12, 0x34, 0x6d, 0x75, 0x73, 0x74, - 0x20, 0x62, 0x65, 0x20, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x20, 0x49, 0x50, 0x76, 0x36, 0x20, 0x43, - 0x49, 0x44, 0x52, 0x20, 0x6e, 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x20, 0x28, 0x65, 0x2e, - 0x67, 0x2e, 0x2c, 0x20, 0x27, 0x66, 0x64, 0x30, 0x30, 0x3a, 0x3a, 0x2f, 0x34, 0x38, 0x27, 0x29, - 0x1a, 0x26, 0x74, 0x68, 0x69, 0x73, 0x20, 0x3d, 0x3d, 0x20, 0x27, 0x27, 0x20, 0x7c, 0x7c, 0x20, - 0x74, 0x68, 0x69, 0x73, 0x2e, 0x69, 0x73, 0x49, 0x70, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x28, - 0x36, 0x2c, 0x20, 0x74, 0x72, 0x75, 0x65, 0x29, 0x52, 0x16, 0x76, 0x69, 0x72, 0x74, 0x75, 0x61, - 0x6c, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x49, 0x70, 0x76, 0x36, 0x43, 0x69, 0x64, 0x72, - 0x12, 0xa1, 0x01, 0x0a, 0x10, 0x73, 0x75, 0x62, 0x6e, 0x65, 0x74, 0x5f, 0x69, 0x70, 0x76, 0x34, - 0x5f, 0x63, 0x69, 0x64, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x77, 0xba, 0x48, 0x74, - 0xba, 0x01, 0x71, 0x0a, 0x0f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x5f, 0x69, 0x70, 0x76, 0x34, 0x5f, - 0x63, 0x69, 0x64, 0x72, 0x12, 0x36, 0x6d, 0x75, 0x73, 0x74, 0x20, 0x62, 0x65, 0x20, 0x76, 0x61, - 0x6c, 0x69, 0x64, 0x20, 0x49, 0x50, 0x76, 0x34, 0x20, 0x43, 0x49, 0x44, 0x52, 0x20, 0x6e, 0x6f, - 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x20, 0x28, 0x65, 0x2e, 0x67, 0x2e, 0x2c, 0x20, 0x27, 0x31, - 0x30, 0x2e, 0x30, 0x2e, 0x31, 0x2e, 0x30, 0x2f, 0x32, 0x34, 0x27, 0x29, 0x1a, 0x26, 0x74, 0x68, - 0x69, 0x73, 0x20, 0x3d, 0x3d, 0x20, 0x27, 0x27, 0x20, 0x7c, 0x7c, 0x20, 0x74, 0x68, 0x69, 0x73, - 0x2e, 0x69, 0x73, 0x49, 0x70, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x28, 0x34, 0x2c, 0x20, 0x74, - 0x72, 0x75, 0x65, 0x29, 0x52, 0x0e, 0x73, 0x75, 0x62, 0x6e, 0x65, 0x74, 0x49, 0x70, 0x76, 0x34, - 0x43, 0x69, 0x64, 0x72, 0x12, 0xa5, 0x01, 0x0a, 0x10, 0x73, 0x75, 0x62, 0x6e, 0x65, 0x74, 0x5f, - 0x69, 0x70, 0x76, 0x36, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x42, - 0x7b, 0xba, 0x48, 0x78, 0xba, 0x01, 0x75, 0x0a, 0x0f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x5f, 0x69, - 0x70, 0x76, 0x36, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x12, 0x3a, 0x6d, 0x75, 0x73, 0x74, 0x20, 0x62, - 0x65, 0x20, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x20, 0x49, 0x50, 0x76, 0x36, 0x20, 0x43, 0x49, 0x44, - 0x52, 0x20, 0x6e, 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x20, 0x28, 0x65, 0x2e, 0x67, 0x2e, - 0x2c, 0x20, 0x27, 0x66, 0x64, 0x30, 0x30, 0x3a, 0x30, 0x3a, 0x30, 0x3a, 0x31, 0x3a, 0x3a, 0x2f, - 0x36, 0x34, 0x27, 0x29, 0x1a, 0x26, 0x74, 0x68, 0x69, 0x73, 0x20, 0x3d, 0x3d, 0x20, 0x27, 0x27, + 0x0a, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x0d, 0x66, 0x61, 0x62, 0x72, 0x69, 0x63, 0x4d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x88, 0x01, 0x01, 0x12, 0x24, 0x0a, 0x0b, 0x6b, 0x38, 0x73, + 0x5f, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x48, 0x02, + 0x52, 0x0a, 0x6b, 0x38, 0x73, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x88, 0x01, 0x01, 0x12, + 0x35, 0x0a, 0x04, 0x73, 0x70, 0x65, 0x63, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x21, 0x2e, + 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, + 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x53, 0x70, 0x65, 0x63, + 0x52, 0x04, 0x73, 0x70, 0x65, 0x63, 0x42, 0x0d, 0x0a, 0x0b, 0x5f, 0x69, 0x73, 0x5f, 0x64, 0x65, + 0x66, 0x61, 0x75, 0x6c, 0x74, 0x42, 0x11, 0x0a, 0x0f, 0x5f, 0x66, 0x61, 0x62, 0x72, 0x69, 0x63, + 0x5f, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x6b, 0x38, 0x73, + 0x5f, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x22, 0x81, 0x02, 0x0a, 0x10, 0x4e, 0x65, 0x74, + 0x77, 0x6f, 0x72, 0x6b, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x53, 0x70, 0x65, 0x63, 0x12, 0x3c, 0x0a, + 0x08, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x20, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, + 0x31, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, + 0x73, 0x52, 0x08, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x73, 0x12, 0x5c, 0x0a, 0x14, 0x64, + 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x5f, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, + 0x69, 0x65, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x6f, 0x73, 0x61, 0x63, + 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4e, 0x65, 0x74, 0x77, + 0x6f, 0x72, 0x6b, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, + 0x74, 0x69, 0x65, 0x73, 0x52, 0x13, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x43, 0x61, 0x70, + 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x12, 0x3b, 0x0a, 0x11, 0x76, 0x69, 0x70, + 0x5f, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x5f, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x05, 0x42, 0x0a, 0xba, 0x48, 0x07, 0x1a, 0x05, 0x18, 0x80, 0x01, 0x28, 0x01, + 0x48, 0x00, 0x52, 0x0f, 0x76, 0x69, 0x70, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x4c, 0x65, 0x6e, + 0x67, 0x74, 0x68, 0x88, 0x01, 0x01, 0x42, 0x14, 0x0a, 0x12, 0x5f, 0x76, 0x69, 0x70, 0x5f, 0x70, + 0x72, 0x65, 0x66, 0x69, 0x78, 0x5f, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x22, 0xe4, 0x09, 0x0a, + 0x0f, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x73, + 0x12, 0xb2, 0x01, 0x0a, 0x19, 0x76, 0x69, 0x72, 0x74, 0x75, 0x61, 0x6c, 0x5f, 0x6e, 0x65, 0x74, + 0x77, 0x6f, 0x72, 0x6b, 0x5f, 0x69, 0x70, 0x76, 0x34, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x42, 0x77, 0xba, 0x48, 0x74, 0xba, 0x01, 0x71, 0x0a, 0x0f, 0x76, 0x61, + 0x6c, 0x69, 0x64, 0x5f, 0x69, 0x70, 0x76, 0x34, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x12, 0x36, 0x6d, + 0x75, 0x73, 0x74, 0x20, 0x62, 0x65, 0x20, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x20, 0x49, 0x50, 0x76, + 0x34, 0x20, 0x43, 0x49, 0x44, 0x52, 0x20, 0x6e, 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x20, + 0x28, 0x65, 0x2e, 0x67, 0x2e, 0x2c, 0x20, 0x27, 0x31, 0x30, 0x2e, 0x30, 0x2e, 0x30, 0x2e, 0x30, + 0x2f, 0x31, 0x36, 0x27, 0x29, 0x1a, 0x26, 0x74, 0x68, 0x69, 0x73, 0x20, 0x3d, 0x3d, 0x20, 0x27, + 0x27, 0x20, 0x7c, 0x7c, 0x20, 0x74, 0x68, 0x69, 0x73, 0x2e, 0x69, 0x73, 0x49, 0x70, 0x50, 0x72, + 0x65, 0x66, 0x69, 0x78, 0x28, 0x34, 0x2c, 0x20, 0x74, 0x72, 0x75, 0x65, 0x29, 0x52, 0x16, 0x76, + 0x69, 0x72, 0x74, 0x75, 0x61, 0x6c, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x49, 0x70, 0x76, + 0x34, 0x43, 0x69, 0x64, 0x72, 0x12, 0xb0, 0x01, 0x0a, 0x19, 0x76, 0x69, 0x72, 0x74, 0x75, 0x61, + 0x6c, 0x5f, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x5f, 0x69, 0x70, 0x76, 0x36, 0x5f, 0x63, + 0x69, 0x64, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x42, 0x75, 0xba, 0x48, 0x72, 0xba, 0x01, + 0x6f, 0x0a, 0x0f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x5f, 0x69, 0x70, 0x76, 0x36, 0x5f, 0x63, 0x69, + 0x64, 0x72, 0x12, 0x34, 0x6d, 0x75, 0x73, 0x74, 0x20, 0x62, 0x65, 0x20, 0x76, 0x61, 0x6c, 0x69, + 0x64, 0x20, 0x49, 0x50, 0x76, 0x36, 0x20, 0x43, 0x49, 0x44, 0x52, 0x20, 0x6e, 0x6f, 0x74, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x20, 0x28, 0x65, 0x2e, 0x67, 0x2e, 0x2c, 0x20, 0x27, 0x66, 0x64, 0x30, + 0x30, 0x3a, 0x3a, 0x2f, 0x34, 0x38, 0x27, 0x29, 0x1a, 0x26, 0x74, 0x68, 0x69, 0x73, 0x20, 0x3d, + 0x3d, 0x20, 0x27, 0x27, 0x20, 0x7c, 0x7c, 0x20, 0x74, 0x68, 0x69, 0x73, 0x2e, 0x69, 0x73, 0x49, + 0x70, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x28, 0x36, 0x2c, 0x20, 0x74, 0x72, 0x75, 0x65, 0x29, + 0x52, 0x16, 0x76, 0x69, 0x72, 0x74, 0x75, 0x61, 0x6c, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, + 0x49, 0x70, 0x76, 0x36, 0x43, 0x69, 0x64, 0x72, 0x12, 0xa1, 0x01, 0x0a, 0x10, 0x73, 0x75, 0x62, + 0x6e, 0x65, 0x74, 0x5f, 0x69, 0x70, 0x76, 0x34, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x42, 0x77, 0xba, 0x48, 0x74, 0xba, 0x01, 0x71, 0x0a, 0x0f, 0x76, 0x61, 0x6c, + 0x69, 0x64, 0x5f, 0x69, 0x70, 0x76, 0x34, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x12, 0x36, 0x6d, 0x75, + 0x73, 0x74, 0x20, 0x62, 0x65, 0x20, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x20, 0x49, 0x50, 0x76, 0x34, + 0x20, 0x43, 0x49, 0x44, 0x52, 0x20, 0x6e, 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x20, 0x28, + 0x65, 0x2e, 0x67, 0x2e, 0x2c, 0x20, 0x27, 0x31, 0x30, 0x2e, 0x30, 0x2e, 0x31, 0x2e, 0x30, 0x2f, + 0x32, 0x34, 0x27, 0x29, 0x1a, 0x26, 0x74, 0x68, 0x69, 0x73, 0x20, 0x3d, 0x3d, 0x20, 0x27, 0x27, 0x20, 0x7c, 0x7c, 0x20, 0x74, 0x68, 0x69, 0x73, 0x2e, 0x69, 0x73, 0x49, 0x70, 0x50, 0x72, 0x65, - 0x66, 0x69, 0x78, 0x28, 0x36, 0x2c, 0x20, 0x74, 0x72, 0x75, 0x65, 0x29, 0x52, 0x0e, 0x73, 0x75, - 0x62, 0x6e, 0x65, 0x74, 0x49, 0x70, 0x76, 0x36, 0x43, 0x69, 0x64, 0x72, 0x12, 0x42, 0x0a, 0x0d, - 0x69, 0x6e, 0x67, 0x72, 0x65, 0x73, 0x73, 0x5f, 0x72, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x03, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, - 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x52, 0x75, - 0x6c, 0x65, 0x52, 0x0c, 0x69, 0x6e, 0x67, 0x72, 0x65, 0x73, 0x73, 0x52, 0x75, 0x6c, 0x65, 0x73, - 0x12, 0x40, 0x0a, 0x0c, 0x65, 0x67, 0x72, 0x65, 0x73, 0x73, 0x5f, 0x72, 0x75, 0x6c, 0x65, 0x73, - 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, - 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, - 0x79, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x0b, 0x65, 0x67, 0x72, 0x65, 0x73, 0x73, 0x52, 0x75, 0x6c, - 0x65, 0x73, 0x12, 0x2c, 0x0a, 0x12, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x5f, 0x6e, 0x61, 0x74, - 0x5f, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, - 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x4e, 0x61, 0x74, 0x47, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, - 0x3a, 0xe8, 0x02, 0xba, 0x48, 0xe4, 0x02, 0x1a, 0xaf, 0x01, 0x0a, 0x29, 0x73, 0x75, 0x62, 0x6e, - 0x65, 0x74, 0x5f, 0x69, 0x70, 0x76, 0x34, 0x5f, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x73, - 0x5f, 0x76, 0x69, 0x72, 0x74, 0x75, 0x61, 0x6c, 0x5f, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, - 0x5f, 0x69, 0x70, 0x76, 0x34, 0x12, 0x3d, 0x73, 0x75, 0x62, 0x6e, 0x65, 0x74, 0x5f, 0x69, 0x70, - 0x76, 0x34, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x20, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x73, - 0x20, 0x76, 0x69, 0x72, 0x74, 0x75, 0x61, 0x6c, 0x5f, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, - 0x5f, 0x69, 0x70, 0x76, 0x34, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x20, 0x74, 0x6f, 0x20, 0x62, 0x65, - 0x20, 0x73, 0x65, 0x74, 0x1a, 0x43, 0x74, 0x68, 0x69, 0x73, 0x2e, 0x73, 0x75, 0x62, 0x6e, 0x65, - 0x74, 0x5f, 0x69, 0x70, 0x76, 0x34, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x20, 0x3d, 0x3d, 0x20, 0x27, - 0x27, 0x20, 0x7c, 0x7c, 0x20, 0x74, 0x68, 0x69, 0x73, 0x2e, 0x76, 0x69, 0x72, 0x74, 0x75, 0x61, - 0x6c, 0x5f, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x5f, 0x69, 0x70, 0x76, 0x34, 0x5f, 0x63, - 0x69, 0x64, 0x72, 0x20, 0x21, 0x3d, 0x20, 0x27, 0x27, 0x1a, 0xaf, 0x01, 0x0a, 0x29, 0x73, 0x75, - 0x62, 0x6e, 0x65, 0x74, 0x5f, 0x69, 0x70, 0x76, 0x36, 0x5f, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, - 0x65, 0x73, 0x5f, 0x76, 0x69, 0x72, 0x74, 0x75, 0x61, 0x6c, 0x5f, 0x6e, 0x65, 0x74, 0x77, 0x6f, - 0x72, 0x6b, 0x5f, 0x69, 0x70, 0x76, 0x36, 0x12, 0x3d, 0x73, 0x75, 0x62, 0x6e, 0x65, 0x74, 0x5f, - 0x69, 0x70, 0x76, 0x36, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x20, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, - 0x65, 0x73, 0x20, 0x76, 0x69, 0x72, 0x74, 0x75, 0x61, 0x6c, 0x5f, 0x6e, 0x65, 0x74, 0x77, 0x6f, - 0x72, 0x6b, 0x5f, 0x69, 0x70, 0x76, 0x36, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x20, 0x74, 0x6f, 0x20, - 0x62, 0x65, 0x20, 0x73, 0x65, 0x74, 0x1a, 0x43, 0x74, 0x68, 0x69, 0x73, 0x2e, 0x73, 0x75, 0x62, - 0x6e, 0x65, 0x74, 0x5f, 0x69, 0x70, 0x76, 0x36, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x20, 0x3d, 0x3d, - 0x20, 0x27, 0x27, 0x20, 0x7c, 0x7c, 0x20, 0x74, 0x68, 0x69, 0x73, 0x2e, 0x76, 0x69, 0x72, 0x74, - 0x75, 0x61, 0x6c, 0x5f, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x5f, 0x69, 0x70, 0x76, 0x36, - 0x5f, 0x63, 0x69, 0x64, 0x72, 0x20, 0x21, 0x3d, 0x20, 0x27, 0x27, 0x22, 0x19, 0x0a, 0x17, 0x4e, - 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x43, 0x6f, 0x6e, 0x73, 0x74, - 0x72, 0x61, 0x69, 0x6e, 0x74, 0x73, 0x22, 0xb5, 0x01, 0x0a, 0x18, 0x4e, 0x65, 0x74, 0x77, 0x6f, - 0x72, 0x6b, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, - 0x69, 0x65, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x5f, - 0x69, 0x70, 0x76, 0x34, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x73, 0x75, 0x70, 0x70, - 0x6f, 0x72, 0x74, 0x73, 0x49, 0x70, 0x76, 0x34, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x75, 0x70, 0x70, - 0x6f, 0x72, 0x74, 0x73, 0x5f, 0x69, 0x70, 0x76, 0x36, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x0c, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x49, 0x70, 0x76, 0x36, 0x12, 0x2e, 0x0a, - 0x13, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x5f, 0x64, 0x75, 0x61, 0x6c, 0x5f, 0x73, - 0x74, 0x61, 0x63, 0x6b, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x73, 0x75, 0x70, 0x70, - 0x6f, 0x72, 0x74, 0x73, 0x44, 0x75, 0x61, 0x6c, 0x53, 0x74, 0x61, 0x63, 0x6b, 0x12, 0x1f, 0x0a, - 0x0b, 0x64, 0x70, 0x75, 0x5f, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x04, 0x20, 0x01, - 0x28, 0x08, 0x52, 0x0a, 0x64, 0x70, 0x75, 0x53, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x22, 0x8b, - 0x01, 0x0a, 0x12, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x53, - 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x38, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x0e, 0x32, 0x22, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, - 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x43, 0x6c, - 0x61, 0x73, 0x73, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, - 0x1d, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x48, 0x00, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x88, 0x01, 0x01, 0x12, 0x10, - 0x0a, 0x03, 0x68, 0x75, 0x62, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x68, 0x75, 0x62, - 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x2a, 0x98, 0x01, 0x0a, - 0x11, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x53, 0x74, 0x61, - 0x74, 0x65, 0x12, 0x23, 0x0a, 0x1f, 0x4e, 0x45, 0x54, 0x57, 0x4f, 0x52, 0x4b, 0x5f, 0x43, 0x4c, - 0x41, 0x53, 0x53, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, - 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x1f, 0x0a, 0x1b, 0x4e, 0x45, 0x54, 0x57, 0x4f, - 0x52, 0x4b, 0x5f, 0x43, 0x4c, 0x41, 0x53, 0x53, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x50, - 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, 0x01, 0x12, 0x1d, 0x0a, 0x19, 0x4e, 0x45, 0x54, 0x57, - 0x4f, 0x52, 0x4b, 0x5f, 0x43, 0x4c, 0x41, 0x53, 0x53, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, - 0x52, 0x45, 0x41, 0x44, 0x59, 0x10, 0x02, 0x12, 0x1e, 0x0a, 0x1a, 0x4e, 0x45, 0x54, 0x57, 0x4f, - 0x52, 0x4b, 0x5f, 0x43, 0x4c, 0x41, 0x53, 0x53, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x46, - 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x03, 0x42, 0xda, 0x01, 0x0a, 0x13, 0x63, 0x6f, 0x6d, 0x2e, - 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x42, - 0x15, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x54, 0x79, 0x70, - 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x4c, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, - 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6f, 0x73, 0x61, 0x63, 0x2d, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, - 0x74, 0x2f, 0x6f, 0x73, 0x61, 0x63, 0x2d, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x69, 0x6e, 0x67, 0x2f, - 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x6f, 0x73, 0x61, - 0x63, 0x2f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x31, 0x3b, 0x70, 0x72, 0x69, - 0x76, 0x61, 0x74, 0x65, 0x76, 0x31, 0xa2, 0x02, 0x03, 0x4f, 0x50, 0x58, 0xaa, 0x02, 0x0f, 0x4f, - 0x73, 0x61, 0x63, 0x2e, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x56, 0x31, 0xca, 0x02, - 0x10, 0x4f, 0x73, 0x61, 0x63, 0x5c, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, 0x5c, 0x56, - 0x31, 0xe2, 0x02, 0x1c, 0x4f, 0x73, 0x61, 0x63, 0x5c, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, - 0x5f, 0x5c, 0x56, 0x31, 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, - 0xea, 0x02, 0x11, 0x4f, 0x73, 0x61, 0x63, 0x3a, 0x3a, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, - 0x3a, 0x3a, 0x56, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x66, 0x69, 0x78, 0x28, 0x34, 0x2c, 0x20, 0x74, 0x72, 0x75, 0x65, 0x29, 0x52, 0x0e, 0x73, 0x75, + 0x62, 0x6e, 0x65, 0x74, 0x49, 0x70, 0x76, 0x34, 0x43, 0x69, 0x64, 0x72, 0x12, 0xa5, 0x01, 0x0a, + 0x10, 0x73, 0x75, 0x62, 0x6e, 0x65, 0x74, 0x5f, 0x69, 0x70, 0x76, 0x36, 0x5f, 0x63, 0x69, 0x64, + 0x72, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x42, 0x7b, 0xba, 0x48, 0x78, 0xba, 0x01, 0x75, 0x0a, + 0x0f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x5f, 0x69, 0x70, 0x76, 0x36, 0x5f, 0x63, 0x69, 0x64, 0x72, + 0x12, 0x3a, 0x6d, 0x75, 0x73, 0x74, 0x20, 0x62, 0x65, 0x20, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x20, + 0x49, 0x50, 0x76, 0x36, 0x20, 0x43, 0x49, 0x44, 0x52, 0x20, 0x6e, 0x6f, 0x74, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x20, 0x28, 0x65, 0x2e, 0x67, 0x2e, 0x2c, 0x20, 0x27, 0x66, 0x64, 0x30, 0x30, 0x3a, + 0x30, 0x3a, 0x30, 0x3a, 0x31, 0x3a, 0x3a, 0x2f, 0x36, 0x34, 0x27, 0x29, 0x1a, 0x26, 0x74, 0x68, + 0x69, 0x73, 0x20, 0x3d, 0x3d, 0x20, 0x27, 0x27, 0x20, 0x7c, 0x7c, 0x20, 0x74, 0x68, 0x69, 0x73, + 0x2e, 0x69, 0x73, 0x49, 0x70, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x28, 0x36, 0x2c, 0x20, 0x74, + 0x72, 0x75, 0x65, 0x29, 0x52, 0x0e, 0x73, 0x75, 0x62, 0x6e, 0x65, 0x74, 0x49, 0x70, 0x76, 0x36, + 0x43, 0x69, 0x64, 0x72, 0x12, 0x42, 0x0a, 0x0d, 0x69, 0x6e, 0x67, 0x72, 0x65, 0x73, 0x73, 0x5f, + 0x72, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6f, 0x73, + 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, + 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x0c, 0x69, 0x6e, 0x67, 0x72, + 0x65, 0x73, 0x73, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x40, 0x0a, 0x0c, 0x65, 0x67, 0x72, 0x65, + 0x73, 0x73, 0x5f, 0x72, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d, + 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, + 0x2e, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x0b, 0x65, + 0x67, 0x72, 0x65, 0x73, 0x73, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x2c, 0x0a, 0x12, 0x65, 0x6e, + 0x61, 0x62, 0x6c, 0x65, 0x5f, 0x6e, 0x61, 0x74, 0x5f, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, + 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x4e, 0x61, + 0x74, 0x47, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x3a, 0xe8, 0x02, 0xba, 0x48, 0xe4, 0x02, 0x1a, + 0xaf, 0x01, 0x0a, 0x29, 0x73, 0x75, 0x62, 0x6e, 0x65, 0x74, 0x5f, 0x69, 0x70, 0x76, 0x34, 0x5f, + 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x73, 0x5f, 0x76, 0x69, 0x72, 0x74, 0x75, 0x61, 0x6c, + 0x5f, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x5f, 0x69, 0x70, 0x76, 0x34, 0x12, 0x3d, 0x73, + 0x75, 0x62, 0x6e, 0x65, 0x74, 0x5f, 0x69, 0x70, 0x76, 0x34, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x20, + 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x73, 0x20, 0x76, 0x69, 0x72, 0x74, 0x75, 0x61, 0x6c, + 0x5f, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x5f, 0x69, 0x70, 0x76, 0x34, 0x5f, 0x63, 0x69, + 0x64, 0x72, 0x20, 0x74, 0x6f, 0x20, 0x62, 0x65, 0x20, 0x73, 0x65, 0x74, 0x1a, 0x43, 0x74, 0x68, + 0x69, 0x73, 0x2e, 0x73, 0x75, 0x62, 0x6e, 0x65, 0x74, 0x5f, 0x69, 0x70, 0x76, 0x34, 0x5f, 0x63, + 0x69, 0x64, 0x72, 0x20, 0x3d, 0x3d, 0x20, 0x27, 0x27, 0x20, 0x7c, 0x7c, 0x20, 0x74, 0x68, 0x69, + 0x73, 0x2e, 0x76, 0x69, 0x72, 0x74, 0x75, 0x61, 0x6c, 0x5f, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, + 0x6b, 0x5f, 0x69, 0x70, 0x76, 0x34, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x20, 0x21, 0x3d, 0x20, 0x27, + 0x27, 0x1a, 0xaf, 0x01, 0x0a, 0x29, 0x73, 0x75, 0x62, 0x6e, 0x65, 0x74, 0x5f, 0x69, 0x70, 0x76, + 0x36, 0x5f, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x73, 0x5f, 0x76, 0x69, 0x72, 0x74, 0x75, + 0x61, 0x6c, 0x5f, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x5f, 0x69, 0x70, 0x76, 0x36, 0x12, + 0x3d, 0x73, 0x75, 0x62, 0x6e, 0x65, 0x74, 0x5f, 0x69, 0x70, 0x76, 0x36, 0x5f, 0x63, 0x69, 0x64, + 0x72, 0x20, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x73, 0x20, 0x76, 0x69, 0x72, 0x74, 0x75, + 0x61, 0x6c, 0x5f, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x5f, 0x69, 0x70, 0x76, 0x36, 0x5f, + 0x63, 0x69, 0x64, 0x72, 0x20, 0x74, 0x6f, 0x20, 0x62, 0x65, 0x20, 0x73, 0x65, 0x74, 0x1a, 0x43, + 0x74, 0x68, 0x69, 0x73, 0x2e, 0x73, 0x75, 0x62, 0x6e, 0x65, 0x74, 0x5f, 0x69, 0x70, 0x76, 0x36, + 0x5f, 0x63, 0x69, 0x64, 0x72, 0x20, 0x3d, 0x3d, 0x20, 0x27, 0x27, 0x20, 0x7c, 0x7c, 0x20, 0x74, + 0x68, 0x69, 0x73, 0x2e, 0x76, 0x69, 0x72, 0x74, 0x75, 0x61, 0x6c, 0x5f, 0x6e, 0x65, 0x74, 0x77, + 0x6f, 0x72, 0x6b, 0x5f, 0x69, 0x70, 0x76, 0x36, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x20, 0x21, 0x3d, + 0x20, 0x27, 0x27, 0x22, 0x19, 0x0a, 0x17, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x43, 0x6c, + 0x61, 0x73, 0x73, 0x43, 0x6f, 0x6e, 0x73, 0x74, 0x72, 0x61, 0x69, 0x6e, 0x74, 0x73, 0x22, 0xb5, + 0x01, 0x0a, 0x18, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x43, + 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x73, + 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x5f, 0x69, 0x70, 0x76, 0x34, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x0c, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x49, 0x70, 0x76, 0x34, + 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x5f, 0x69, 0x70, 0x76, + 0x36, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, + 0x73, 0x49, 0x70, 0x76, 0x36, 0x12, 0x2e, 0x0a, 0x13, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, + 0x73, 0x5f, 0x64, 0x75, 0x61, 0x6c, 0x5f, 0x73, 0x74, 0x61, 0x63, 0x6b, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x11, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x44, 0x75, 0x61, 0x6c, + 0x53, 0x74, 0x61, 0x63, 0x6b, 0x12, 0x1f, 0x0a, 0x0b, 0x64, 0x70, 0x75, 0x5f, 0x73, 0x75, 0x70, + 0x70, 0x6f, 0x72, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x64, 0x70, 0x75, 0x53, + 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x22, 0x8b, 0x01, 0x0a, 0x12, 0x4e, 0x65, 0x74, 0x77, 0x6f, + 0x72, 0x6b, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x38, 0x0a, + 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x22, 0x2e, 0x6f, + 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4e, + 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x53, 0x74, 0x61, 0x74, 0x65, + 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x1d, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x88, 0x01, 0x01, 0x12, 0x10, 0x0a, 0x03, 0x68, 0x75, 0x62, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x03, 0x68, 0x75, 0x62, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x6d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x2a, 0x98, 0x01, 0x0a, 0x11, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, + 0x43, 0x6c, 0x61, 0x73, 0x73, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x23, 0x0a, 0x1f, 0x4e, 0x45, + 0x54, 0x57, 0x4f, 0x52, 0x4b, 0x5f, 0x43, 0x4c, 0x41, 0x53, 0x53, 0x5f, 0x53, 0x54, 0x41, 0x54, + 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, + 0x1f, 0x0a, 0x1b, 0x4e, 0x45, 0x54, 0x57, 0x4f, 0x52, 0x4b, 0x5f, 0x43, 0x4c, 0x41, 0x53, 0x53, + 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, 0x01, + 0x12, 0x1d, 0x0a, 0x19, 0x4e, 0x45, 0x54, 0x57, 0x4f, 0x52, 0x4b, 0x5f, 0x43, 0x4c, 0x41, 0x53, + 0x53, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x52, 0x45, 0x41, 0x44, 0x59, 0x10, 0x02, 0x12, + 0x1e, 0x0a, 0x1a, 0x4e, 0x45, 0x54, 0x57, 0x4f, 0x52, 0x4b, 0x5f, 0x43, 0x4c, 0x41, 0x53, 0x53, + 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x03, 0x42, + 0xda, 0x01, 0x0a, 0x13, 0x63, 0x6f, 0x6d, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, + 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x42, 0x15, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, + 0x43, 0x6c, 0x61, 0x73, 0x73, 0x54, 0x79, 0x70, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x50, 0x01, + 0x5a, 0x4c, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6f, 0x73, 0x61, + 0x63, 0x2d, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x2f, 0x6f, 0x73, 0x61, 0x63, 0x2d, 0x6d, + 0x65, 0x74, 0x65, 0x72, 0x69, 0x6e, 0x67, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, + 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x6f, 0x73, 0x61, 0x63, 0x2f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, + 0x65, 0x2f, 0x76, 0x31, 0x3b, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x76, 0x31, 0xa2, 0x02, + 0x03, 0x4f, 0x50, 0x58, 0xaa, 0x02, 0x0f, 0x4f, 0x73, 0x61, 0x63, 0x2e, 0x50, 0x72, 0x69, 0x76, + 0x61, 0x74, 0x65, 0x2e, 0x56, 0x31, 0xca, 0x02, 0x10, 0x4f, 0x73, 0x61, 0x63, 0x5c, 0x50, 0x72, + 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, 0x5c, 0x56, 0x31, 0xe2, 0x02, 0x1c, 0x4f, 0x73, 0x61, 0x63, + 0x5c, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, 0x5c, 0x56, 0x31, 0x5c, 0x47, 0x50, 0x42, + 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0xea, 0x02, 0x11, 0x4f, 0x73, 0x61, 0x63, 0x3a, + 0x3a, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x3a, 0x3a, 0x56, 0x31, 0x62, 0x06, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x33, }) var ( diff --git a/osac-metering/metering-service/internal/api/osac/private/v1/project_type.pb.go b/osac-metering/metering-service/internal/api/osac/private/v1/project_type.pb.go index d8530bcc2..c706cd883 100644 --- a/osac-metering/metering-service/internal/api/osac/private/v1/project_type.pb.go +++ b/osac-metering/metering-service/internal/api/osac/private/v1/project_type.pb.go @@ -229,7 +229,7 @@ type Project struct { // // Project names follow a hierarchical dot-separated DNS label format (e.g., "org.team.project"). // Each segment must be a valid DNS label: max 63 characters, lowercase letters (a-z), digits (0-9), - // and hyphens, with alphanumeric start and end. Empty name is allowed for the root project. + // and hyphens, with alphanumeric start and end. // // Note: Uses (buf.validate.field).ignore = IGNORE_ALWAYS to skip standard Metadata.name pattern // validation (which doesn't allow dots). Project-specific validation is handled by message-level CEL. @@ -547,7 +547,7 @@ var file_osac_private_v1_project_type_proto_rawDesc = string([]byte{ 0x61, 0x74, 0x75, 0x73, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x23, 0x6f, 0x73, 0x61, 0x63, 0x2f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x31, 0x2f, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x2e, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xa9, 0x04, 0x0a, 0x07, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, + 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x96, 0x04, 0x0a, 0x07, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x3d, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, @@ -559,95 +559,94 @@ var file_osac_private_v1_project_type_proto_rawDesc = string([]byte{ 0x63, 0x12, 0x36, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, - 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x3a, 0xe4, 0x02, 0xba, 0x48, 0xe0, 0x02, - 0x1a, 0xdd, 0x02, 0x0a, 0x15, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x6e, 0x61, 0x6d, - 0x65, 0x5f, 0x73, 0x65, 0x67, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x94, 0x01, 0x70, 0x72, 0x6f, - 0x6a, 0x65, 0x63, 0x74, 0x20, 0x6e, 0x61, 0x6d, 0x65, 0x20, 0x6d, 0x75, 0x73, 0x74, 0x20, 0x62, - 0x65, 0x20, 0x65, 0x6d, 0x70, 0x74, 0x79, 0x20, 0x28, 0x72, 0x6f, 0x6f, 0x74, 0x20, 0x70, 0x72, - 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x29, 0x20, 0x6f, 0x72, 0x20, 0x64, 0x6f, 0x74, 0x2d, 0x73, 0x65, - 0x70, 0x61, 0x72, 0x61, 0x74, 0x65, 0x64, 0x20, 0x44, 0x4e, 0x53, 0x20, 0x6c, 0x61, 0x62, 0x65, - 0x6c, 0x73, 0x20, 0x28, 0x65, 0x61, 0x63, 0x68, 0x20, 0x73, 0x65, 0x67, 0x6d, 0x65, 0x6e, 0x74, - 0x3a, 0x20, 0x6d, 0x61, 0x78, 0x20, 0x36, 0x33, 0x20, 0x63, 0x68, 0x61, 0x72, 0x73, 0x2c, 0x20, - 0x6c, 0x6f, 0x77, 0x65, 0x72, 0x63, 0x61, 0x73, 0x65, 0x20, 0x61, 0x2d, 0x7a, 0x2f, 0x30, 0x2d, - 0x39, 0x2f, 0x68, 0x79, 0x70, 0x68, 0x65, 0x6e, 0x2c, 0x20, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x6e, - 0x75, 0x6d, 0x65, 0x72, 0x69, 0x63, 0x20, 0x73, 0x74, 0x61, 0x72, 0x74, 0x2f, 0x65, 0x6e, 0x64, - 0x29, 0x1a, 0xac, 0x01, 0x74, 0x68, 0x69, 0x73, 0x2e, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, - 0x61, 0x2e, 0x6e, 0x61, 0x6d, 0x65, 0x20, 0x3d, 0x3d, 0x20, 0x27, 0x27, 0x20, 0x7c, 0x7c, 0x20, - 0x74, 0x68, 0x69, 0x73, 0x2e, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x2e, 0x6e, 0x61, - 0x6d, 0x65, 0x2e, 0x73, 0x70, 0x6c, 0x69, 0x74, 0x28, 0x27, 0x2e, 0x27, 0x29, 0x2e, 0x61, 0x6c, - 0x6c, 0x28, 0x73, 0x65, 0x67, 0x6d, 0x65, 0x6e, 0x74, 0x2c, 0x20, 0x73, 0x65, 0x67, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x73, 0x69, 0x7a, 0x65, 0x28, 0x29, 0x20, 0x3e, 0x20, 0x30, 0x20, 0x26, 0x26, - 0x20, 0x73, 0x65, 0x67, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x73, 0x69, 0x7a, 0x65, 0x28, 0x29, 0x20, - 0x3c, 0x3d, 0x20, 0x36, 0x33, 0x20, 0x26, 0x26, 0x20, 0x73, 0x65, 0x67, 0x6d, 0x65, 0x6e, 0x74, - 0x2e, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x65, 0x73, 0x28, 0x27, 0x5e, 0x5b, 0x61, 0x2d, 0x7a, 0x30, - 0x2d, 0x39, 0x5d, 0x28, 0x5b, 0x61, 0x2d, 0x7a, 0x30, 0x2d, 0x39, 0x2d, 0x5d, 0x7b, 0x30, 0x2c, - 0x36, 0x31, 0x7d, 0x5b, 0x61, 0x2d, 0x7a, 0x30, 0x2d, 0x39, 0x5d, 0x29, 0x3f, 0x27, 0x29, 0x29, - 0x22, 0x5a, 0x0a, 0x0b, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x70, 0x65, 0x63, 0x12, - 0x14, 0x0a, 0x05, 0x74, 0x69, 0x74, 0x6c, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, - 0x74, 0x69, 0x74, 0x6c, 0x65, 0x12, 0x25, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, - 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0b, 0x64, 0x65, - 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x88, 0x01, 0x01, 0x42, 0x0e, 0x0a, 0x0c, - 0x5f, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0xb2, 0x01, 0x0a, - 0x0d, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x33, - 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1d, 0x2e, - 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, - 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x05, 0x73, 0x74, - 0x61, 0x74, 0x65, 0x12, 0x1d, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x88, - 0x01, 0x01, 0x12, 0x41, 0x0a, 0x0a, 0x63, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x73, - 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, - 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, - 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x0a, 0x63, 0x6f, 0x6e, 0x64, 0x69, - 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, - 0x65, 0x22, 0xda, 0x01, 0x0a, 0x10, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x43, 0x6f, 0x6e, - 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x39, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x0e, 0x32, 0x25, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, - 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x43, 0x6f, - 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, - 0x65, 0x12, 0x38, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x0e, 0x32, 0x20, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, - 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x74, 0x61, - 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x1b, 0x0a, 0x06, 0x72, - 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x06, 0x72, - 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, - 0x61, 0x67, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x07, 0x6d, 0x65, 0x73, - 0x73, 0x61, 0x67, 0x65, 0x88, 0x01, 0x01, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x72, 0x65, 0x61, 0x73, - 0x6f, 0x6e, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x2a, 0xb9, - 0x01, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, - 0x1d, 0x0a, 0x19, 0x50, 0x52, 0x4f, 0x4a, 0x45, 0x43, 0x54, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, - 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x19, - 0x0a, 0x15, 0x50, 0x52, 0x4f, 0x4a, 0x45, 0x43, 0x54, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, - 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, 0x01, 0x12, 0x18, 0x0a, 0x14, 0x50, 0x52, 0x4f, - 0x4a, 0x45, 0x43, 0x54, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x41, 0x43, 0x54, 0x49, 0x56, - 0x45, 0x10, 0x02, 0x12, 0x18, 0x0a, 0x14, 0x50, 0x52, 0x4f, 0x4a, 0x45, 0x43, 0x54, 0x5f, 0x53, - 0x54, 0x41, 0x54, 0x45, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x03, 0x12, 0x1a, 0x0a, - 0x16, 0x50, 0x52, 0x4f, 0x4a, 0x45, 0x43, 0x54, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x44, - 0x45, 0x4c, 0x45, 0x54, 0x49, 0x4e, 0x47, 0x10, 0x04, 0x12, 0x1f, 0x0a, 0x1b, 0x50, 0x52, 0x4f, - 0x4a, 0x45, 0x43, 0x54, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x44, 0x45, 0x4c, 0x45, 0x54, - 0x45, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x05, 0x2a, 0x8d, 0x01, 0x0a, 0x14, 0x50, - 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x54, - 0x79, 0x70, 0x65, 0x12, 0x26, 0x0a, 0x22, 0x50, 0x52, 0x4f, 0x4a, 0x45, 0x43, 0x54, 0x5f, 0x43, - 0x4f, 0x4e, 0x44, 0x49, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x55, 0x4e, - 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x23, 0x0a, 0x1f, 0x50, - 0x52, 0x4f, 0x4a, 0x45, 0x43, 0x54, 0x5f, 0x43, 0x4f, 0x4e, 0x44, 0x49, 0x54, 0x49, 0x4f, 0x4e, - 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x48, 0x55, 0x42, 0x5f, 0x53, 0x59, 0x4e, 0x43, 0x10, 0x01, - 0x12, 0x28, 0x0a, 0x24, 0x50, 0x52, 0x4f, 0x4a, 0x45, 0x43, 0x54, 0x5f, 0x43, 0x4f, 0x4e, 0x44, - 0x49, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x4b, 0x45, 0x59, 0x43, 0x4c, - 0x4f, 0x41, 0x4b, 0x5f, 0x53, 0x59, 0x4e, 0x43, 0x10, 0x02, 0x42, 0xd5, 0x01, 0x0a, 0x13, 0x63, - 0x6f, 0x6d, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, - 0x76, 0x31, 0x42, 0x10, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x54, 0x79, 0x70, 0x65, 0x50, - 0x72, 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x4c, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, - 0x6f, 0x6d, 0x2f, 0x6f, 0x73, 0x61, 0x63, 0x2d, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x2f, - 0x6f, 0x73, 0x61, 0x63, 0x2d, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x69, 0x6e, 0x67, 0x2f, 0x69, 0x6e, - 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x6f, 0x73, 0x61, 0x63, 0x2f, - 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x31, 0x3b, 0x70, 0x72, 0x69, 0x76, 0x61, - 0x74, 0x65, 0x76, 0x31, 0xa2, 0x02, 0x03, 0x4f, 0x50, 0x58, 0xaa, 0x02, 0x0f, 0x4f, 0x73, 0x61, - 0x63, 0x2e, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x56, 0x31, 0xca, 0x02, 0x10, 0x4f, - 0x73, 0x61, 0x63, 0x5c, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, 0x5c, 0x56, 0x31, 0xe2, - 0x02, 0x1c, 0x4f, 0x73, 0x61, 0x63, 0x5c, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, 0x5c, - 0x56, 0x31, 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0xea, 0x02, - 0x11, 0x4f, 0x73, 0x61, 0x63, 0x3a, 0x3a, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x3a, 0x3a, - 0x56, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x3a, 0xd1, 0x02, 0xba, 0x48, 0xcd, 0x02, + 0x1a, 0xca, 0x02, 0x0a, 0x15, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x6e, 0x61, 0x6d, + 0x65, 0x5f, 0x73, 0x65, 0x67, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x7c, 0x70, 0x72, 0x6f, 0x6a, + 0x65, 0x63, 0x74, 0x20, 0x6e, 0x61, 0x6d, 0x65, 0x20, 0x6d, 0x75, 0x73, 0x74, 0x20, 0x62, 0x65, + 0x20, 0x64, 0x6f, 0x74, 0x2d, 0x73, 0x65, 0x70, 0x61, 0x72, 0x61, 0x74, 0x65, 0x64, 0x20, 0x44, + 0x4e, 0x53, 0x20, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x20, 0x28, 0x65, 0x61, 0x63, 0x68, 0x20, + 0x73, 0x65, 0x67, 0x6d, 0x65, 0x6e, 0x74, 0x3a, 0x20, 0x6d, 0x61, 0x78, 0x20, 0x36, 0x33, 0x20, + 0x63, 0x68, 0x61, 0x72, 0x73, 0x2c, 0x20, 0x6c, 0x6f, 0x77, 0x65, 0x72, 0x63, 0x61, 0x73, 0x65, + 0x20, 0x61, 0x2d, 0x7a, 0x2f, 0x30, 0x2d, 0x39, 0x2f, 0x68, 0x79, 0x70, 0x68, 0x65, 0x6e, 0x2c, + 0x20, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x6e, 0x75, 0x6d, 0x65, 0x72, 0x69, 0x63, 0x20, 0x73, 0x74, + 0x61, 0x72, 0x74, 0x2f, 0x65, 0x6e, 0x64, 0x29, 0x1a, 0xb2, 0x01, 0x74, 0x68, 0x69, 0x73, 0x2e, + 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x2e, 0x6e, 0x61, 0x6d, 0x65, 0x2e, 0x73, 0x69, + 0x7a, 0x65, 0x28, 0x29, 0x20, 0x3e, 0x20, 0x30, 0x20, 0x26, 0x26, 0x20, 0x74, 0x68, 0x69, 0x73, + 0x2e, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x2e, 0x6e, 0x61, 0x6d, 0x65, 0x2e, 0x73, + 0x70, 0x6c, 0x69, 0x74, 0x28, 0x27, 0x2e, 0x27, 0x29, 0x2e, 0x61, 0x6c, 0x6c, 0x28, 0x73, 0x65, + 0x67, 0x6d, 0x65, 0x6e, 0x74, 0x2c, 0x20, 0x73, 0x65, 0x67, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x73, + 0x69, 0x7a, 0x65, 0x28, 0x29, 0x20, 0x3e, 0x20, 0x30, 0x20, 0x26, 0x26, 0x20, 0x73, 0x65, 0x67, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x73, 0x69, 0x7a, 0x65, 0x28, 0x29, 0x20, 0x3c, 0x3d, 0x20, 0x36, + 0x33, 0x20, 0x26, 0x26, 0x20, 0x73, 0x65, 0x67, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x6d, 0x61, 0x74, + 0x63, 0x68, 0x65, 0x73, 0x28, 0x27, 0x5e, 0x5b, 0x61, 0x2d, 0x7a, 0x30, 0x2d, 0x39, 0x5d, 0x28, + 0x5b, 0x61, 0x2d, 0x7a, 0x30, 0x2d, 0x39, 0x2d, 0x5d, 0x7b, 0x30, 0x2c, 0x36, 0x31, 0x7d, 0x5b, + 0x61, 0x2d, 0x7a, 0x30, 0x2d, 0x39, 0x5d, 0x29, 0x3f, 0x24, 0x27, 0x29, 0x29, 0x22, 0x5a, 0x0a, + 0x0b, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x70, 0x65, 0x63, 0x12, 0x14, 0x0a, 0x05, + 0x74, 0x69, 0x74, 0x6c, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x69, 0x74, + 0x6c, 0x65, 0x12, 0x25, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, + 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, + 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x88, 0x01, 0x01, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x64, 0x65, + 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0xb2, 0x01, 0x0a, 0x0d, 0x50, 0x72, + 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x33, 0x0a, 0x05, 0x73, + 0x74, 0x61, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1d, 0x2e, 0x6f, 0x73, 0x61, + 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x72, 0x6f, + 0x6a, 0x65, 0x63, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, + 0x12, 0x1d, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x48, 0x00, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x88, 0x01, 0x01, 0x12, + 0x41, 0x0a, 0x0a, 0x63, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x03, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, + 0x74, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x43, 0x6f, 0x6e, + 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x0a, 0x63, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, + 0x6e, 0x73, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0xda, + 0x01, 0x0a, 0x10, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, + 0x69, 0x6f, 0x6e, 0x12, 0x39, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0e, 0x32, 0x25, 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, + 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x43, 0x6f, 0x6e, 0x64, 0x69, + 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x38, + 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x20, + 0x2e, 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, + 0x2e, 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, + 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x1b, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, + 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, + 0x6f, 0x6e, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x65, 0x88, 0x01, 0x01, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x42, + 0x0a, 0x0a, 0x08, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x2a, 0xb9, 0x01, 0x0a, 0x0c, + 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x1d, 0x0a, 0x19, + 0x50, 0x52, 0x4f, 0x4a, 0x45, 0x43, 0x54, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x55, 0x4e, + 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x19, 0x0a, 0x15, 0x50, + 0x52, 0x4f, 0x4a, 0x45, 0x43, 0x54, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x50, 0x45, 0x4e, + 0x44, 0x49, 0x4e, 0x47, 0x10, 0x01, 0x12, 0x18, 0x0a, 0x14, 0x50, 0x52, 0x4f, 0x4a, 0x45, 0x43, + 0x54, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x41, 0x43, 0x54, 0x49, 0x56, 0x45, 0x10, 0x02, + 0x12, 0x18, 0x0a, 0x14, 0x50, 0x52, 0x4f, 0x4a, 0x45, 0x43, 0x54, 0x5f, 0x53, 0x54, 0x41, 0x54, + 0x45, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x03, 0x12, 0x1a, 0x0a, 0x16, 0x50, 0x52, + 0x4f, 0x4a, 0x45, 0x43, 0x54, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x44, 0x45, 0x4c, 0x45, + 0x54, 0x49, 0x4e, 0x47, 0x10, 0x04, 0x12, 0x1f, 0x0a, 0x1b, 0x50, 0x52, 0x4f, 0x4a, 0x45, 0x43, + 0x54, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x5f, 0x46, + 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x05, 0x2a, 0x8d, 0x01, 0x0a, 0x14, 0x50, 0x72, 0x6f, 0x6a, + 0x65, 0x63, 0x74, 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, + 0x12, 0x26, 0x0a, 0x22, 0x50, 0x52, 0x4f, 0x4a, 0x45, 0x43, 0x54, 0x5f, 0x43, 0x4f, 0x4e, 0x44, + 0x49, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, + 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x23, 0x0a, 0x1f, 0x50, 0x52, 0x4f, 0x4a, + 0x45, 0x43, 0x54, 0x5f, 0x43, 0x4f, 0x4e, 0x44, 0x49, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x59, + 0x50, 0x45, 0x5f, 0x48, 0x55, 0x42, 0x5f, 0x53, 0x59, 0x4e, 0x43, 0x10, 0x01, 0x12, 0x28, 0x0a, + 0x24, 0x50, 0x52, 0x4f, 0x4a, 0x45, 0x43, 0x54, 0x5f, 0x43, 0x4f, 0x4e, 0x44, 0x49, 0x54, 0x49, + 0x4f, 0x4e, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x4b, 0x45, 0x59, 0x43, 0x4c, 0x4f, 0x41, 0x4b, + 0x5f, 0x53, 0x59, 0x4e, 0x43, 0x10, 0x02, 0x42, 0xd5, 0x01, 0x0a, 0x13, 0x63, 0x6f, 0x6d, 0x2e, + 0x6f, 0x73, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x76, 0x31, 0x42, + 0x10, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x54, 0x79, 0x70, 0x65, 0x50, 0x72, 0x6f, 0x74, + 0x6f, 0x50, 0x01, 0x5a, 0x4c, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, + 0x6f, 0x73, 0x61, 0x63, 0x2d, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x2f, 0x6f, 0x73, 0x61, + 0x63, 0x2d, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x69, 0x6e, 0x67, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, + 0x6e, 0x61, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x6f, 0x73, 0x61, 0x63, 0x2f, 0x70, 0x72, 0x69, + 0x76, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x31, 0x3b, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x76, + 0x31, 0xa2, 0x02, 0x03, 0x4f, 0x50, 0x58, 0xaa, 0x02, 0x0f, 0x4f, 0x73, 0x61, 0x63, 0x2e, 0x50, + 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x56, 0x31, 0xca, 0x02, 0x10, 0x4f, 0x73, 0x61, 0x63, + 0x5c, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, 0x5c, 0x56, 0x31, 0xe2, 0x02, 0x1c, 0x4f, + 0x73, 0x61, 0x63, 0x5c, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, 0x5c, 0x56, 0x31, 0x5c, + 0x47, 0x50, 0x42, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0xea, 0x02, 0x11, 0x4f, 0x73, + 0x61, 0x63, 0x3a, 0x3a, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x3a, 0x3a, 0x56, 0x31, 0x62, + 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, }) var ( diff --git a/osac-metering/metering-service/internal/database/migrations/0_create_metering_resource_state.up.sql b/osac-metering/metering-service/internal/database/migrations/0_create_metering_resource_state.up.sql index e7302c7cf..26f28dbdd 100644 --- a/osac-metering/metering-service/internal/database/migrations/0_create_metering_resource_state.up.sql +++ b/osac-metering/metering-service/internal/database/migrations/0_create_metering_resource_state.up.sql @@ -18,6 +18,7 @@ CREATE TABLE metering_resource_state ( transition_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), fulfillment_version INT NOT NULL, billing_dimensions JSONB NOT NULL DEFAULT '{}'::JSONB, + component_billable_since JSONB NOT NULL DEFAULT '{}'::JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); diff --git a/osac-metering/metering-service/internal/events/cluster.go b/osac-metering/metering-service/internal/events/cluster.go index 985cf8e2b..a8b1f5338 100644 --- a/osac-metering/metering-service/internal/events/cluster.go +++ b/osac-metering/metering-service/internal/events/cluster.go @@ -11,7 +11,6 @@ package events import ( "fmt" - "math" "sort" "strings" "time" @@ -190,7 +189,7 @@ func ClusterBillingDimensions(cl *privatev1.Cluster) map[string]any { if t := spec.GetTemplate(); t != nil { dims["cluster_template"] = t.GetName() } - if vn := spec.GetVersionName(); vn != "" { + if vn := spec.GetVersion().GetName(); vn != "" { dims[DimensionReleaseImage] = vn } @@ -256,9 +255,12 @@ func (cr ComponentRecord) FlatBillingDimensions() map[string]any { // DecomposeClusterComponents extracts N+1 component records from stored // billing dimensions. Used by Watch Consumer, Heartbeat Generator, and // Reconciler to fan out one cluster into per-component events. Returns -// ErrDataQuality if any component's node_count is corrupt — the caller must -// not proceed on a partial or wrong decomposition (fail fast, consistent -// with every other data-quality check in this package). +// ErrDataQuality if the components list itself is missing or malformed — +// per-component node_count is trusted as-is: fulfillment-service's API +// rejects non-positive node set sizes at write time (see +// PrivateClustersServer's node-set-size check), and ClusterBillingDimensions +// always populates node_count from that same validated field, so every +// caller-supplied value is already known good. func DecomposeClusterComponents(billingDims map[string]any) ([]ComponentRecord, error) { clusterTemplate, _ := billingDims["cluster_template"].(string) releaseImage, _ := billingDims[DimensionReleaseImage].(string) @@ -283,13 +285,8 @@ func DecomposeClusterComponents(billingDims map[string]any) ([]ComponentRecord, component, _ := cm["component"].(string) hostType, _ := cm["host_type"].(string) - var nodeCount int32 - if nc, ok := toFloat64(cm["node_count"]); ok { - if nc != math.Trunc(nc) || nc < 0 || nc > math.MaxInt32 { - return nil, fmt.Errorf("%w: node_count %v for node_set %q is not a valid non-negative int32", ErrDataQuality, nc, nodeSet) - } - nodeCount = int32(nc) - } + nc, _ := toFloat64(cm["node_count"]) + nodeCount := int32(nc) records = append(records, ComponentRecord{ NodeSet: nodeSet, @@ -381,3 +378,42 @@ func ChangedComponents(oldDims, newDims map[string]any) ([]ComponentRecord, erro return changed, nil } + +// NextComponentBillableSince returns the per-component "billable since" +// timestamps for newDims: a component unchanged from oldDims carries forward +// its entry from oldSince, a new or changed component resets to now. Callers +// pass oldDims/oldSince as nil to force every current component to reset +// (e.g. a resource newly becoming billable, where any prior per-component +// history is no longer relevant). Resource types that don't decompose into +// components (no "components" key in newDims) return nil. +// +// Reuses ChangedComponents rather than re-implementing the old-vs-new diff, +// so this can never disagree with the changed-component set a caller +// actually publishes for the same transition. +func NextComponentBillableSince(oldDims map[string]any, oldSince map[string]time.Time, newDims map[string]any, now time.Time) map[string]time.Time { + newRecords, err := DecomposeClusterComponents(newDims) + if err != nil { + return nil + } + + // oldDims decomposition failing (e.g. first-ever creation, no prior + // components) just means every current component is treated as changed + // below — correct, since there is no prior state to carry forward. + changed, _ := ChangedComponents(oldDims, newDims) + changedSet := make(map[string]bool, len(changed)) + for _, c := range changed { + changedSet[c.NodeSet] = true + } + + since := make(map[string]time.Time, len(newRecords)) + for _, r := range newRecords { + if !changedSet[r.NodeSet] { + if t, ok := oldSince[r.NodeSet]; ok { + since[r.NodeSet] = t + continue + } + } + since[r.NodeSet] = now + } + return since +} diff --git a/osac-metering/metering-service/internal/events/cluster_test.go b/osac-metering/metering-service/internal/events/cluster_test.go index 961913dc9..73af42820 100644 --- a/osac-metering/metering-service/internal/events/cluster_test.go +++ b/osac-metering/metering-service/internal/events/cluster_test.go @@ -4,7 +4,6 @@ import ( "encoding/json" "errors" "fmt" - "math" cloudevents "github.com/cloudevents/sdk-go/v2" . "github.com/onsi/ginkgo/v2" @@ -15,8 +14,6 @@ import ( "github.com/osac-project/osac-metering/internal/events" ) -func strPtr(s string) *string { return &s } - var _ = Describe("CaaS Cluster Mapper", func() { var cl *privatev1.Cluster @@ -32,7 +29,7 @@ var _ = Describe("CaaS Cluster Mapper", func() { Spec: &privatev1.ClusterSpec{ Template: &privatev1.ClusterTemplateReference{Id: "ocp-ci-small", Name: "ocp-ci-small"}, CatalogItem: &privatev1.ClusterCatalogItemReference{Id: "cluster-catalog-1", Name: "cluster-catalog-1"}, - VersionName: strPtr("quay.io/openshift-release-dev/ocp-release:4.17.0-x86_64"), + Version: &privatev1.ClusterVersionReference{Id: "4.17.0", Name: "4.17.0"}, NodeSets: map[string]*privatev1.ClusterNodeSet{ "gpu-workers": {HostType: &privatev1.HostTypeReference{Name: "gpu-h100"}, Size: 2}, "cpu-workers": {HostType: &privatev1.HostTypeReference{Name: "cpu-only"}, Size: 3}, @@ -301,7 +298,7 @@ var _ = Describe("CaaS Cluster Mapper", func() { It("includes cluster_template, release_image, and full components breakdown", func() { dims := events.ClusterBillingDimensions(cl) Expect(dims["cluster_template"]).To(Equal("ocp-ci-small")) - Expect(dims["release_image"]).To(Equal("quay.io/openshift-release-dev/ocp-release:4.17.0-x86_64")) + Expect(dims["release_image"]).To(Equal("4.17.0")) components, ok := dims["components"].([]any) Expect(ok).To(BeTrue(), "components must be []any for DecomposeClusterComponents compatibility") @@ -330,7 +327,7 @@ var _ = Describe("CaaS Cluster Mapper", func() { }) It("omits release_image when nil", func() { - cl.Spec.VersionName = nil + cl.Spec.Version = nil dims := events.ClusterBillingDimensions(cl) Expect(dims).NotTo(HaveKey("release_image")) }) @@ -575,53 +572,6 @@ var _ = Describe("DecomposeClusterComponents", func() { Expect(errors.Is(err, events.ErrDataQuality)).To(BeTrue()) }) - It("rejects a component with fractional node_count instead of billing a truncated value", func() { - dims := map[string]any{ - "cluster_template": "tmpl", - "components": []any{ - map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": 2.7}, - }, - } - _, err := events.DecomposeClusterComponents(dims) - Expect(errors.Is(err, events.ErrDataQuality)).To(BeTrue(), - "a corrupt fractional node_count must not silently produce a truncated billed value") - }) - - It("rejects a component with negative node_count", func() { - dims := map[string]any{ - "cluster_template": "tmpl", - "components": []any{ - map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": -1.0}, - }, - } - _, err := events.DecomposeClusterComponents(dims) - Expect(errors.Is(err, events.ErrDataQuality)).To(BeTrue()) - }) - - It("rejects a component with node_count exceeding int32 range", func() { - dims := map[string]any{ - "cluster_template": "tmpl", - "components": []any{ - map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": float64(math.MaxInt32) + 1}, - }, - } - _, err := events.DecomposeClusterComponents(dims) - Expect(errors.Is(err, events.ErrDataQuality)).To(BeTrue(), - "an out-of-range node_count must not silently wrap to a negative or truncated billed value") - }) - - It("rejects the whole decomposition when one sibling's node_count is corrupt, rather than leaking partial results", func() { - dims := map[string]any{ - "cluster_template": "tmpl", - "components": []any{ - map[string]any{"node_set": "_control_plane", "component": "control_plane", "host_type": "_control_plane", "node_count": int32(1)}, - map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": -1.0}, - }, - } - records, err := events.DecomposeClusterComponents(dims) - Expect(errors.Is(err, events.ErrDataQuality)).To(BeTrue()) - Expect(records).To(BeNil(), "a corrupt component must fail the whole decomposition, not leak partial results for its healthy siblings") - }) }) var _ = Describe("ComponentRecord", func() { diff --git a/osac-metering/metering-service/internal/projection/postgres.go b/osac-metering/metering-service/internal/projection/postgres.go index 9e8379acf..5ed2e23bb 100644 --- a/osac-metering/metering-service/internal/projection/postgres.go +++ b/osac-metering/metering-service/internal/projection/postgres.go @@ -33,7 +33,7 @@ func (s *PostgresStore) Get(ctx context.Context, resourceID string) (*ResourceSt SELECT resource_id, resource_type, tenant_id, project_id, current_state, previous_state, is_billable, billable_since, last_heartbeat_at, transition_time, fulfillment_version, - billing_dimensions + billing_dimensions, component_billable_since FROM metering_resource_state WHERE resource_id = $1`, resourceID) @@ -53,6 +53,10 @@ func (s *PostgresStore) Upsert(ctx context.Context, state ResourceState) error { if err != nil { return fmt.Errorf("marshaling billing dimensions: %w", err) } + componentSince, err := json.Marshal(state.ComponentBillableSince) + if err != nil { + return fmt.Errorf("marshaling component billable since: %w", err) + } tx, err := s.pool.Begin(ctx) if err != nil { @@ -84,8 +88,8 @@ func (s *PostgresStore) Upsert(ctx context.Context, state ResourceState) error { resource_id, resource_type, tenant_id, project_id, current_state, previous_state, is_billable, billable_since, last_heartbeat_at, transition_time, fulfillment_version, - billing_dimensions, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW()) + billing_dimensions, component_billable_since, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW()) ON CONFLICT (resource_id) DO UPDATE SET resource_type = EXCLUDED.resource_type, tenant_id = EXCLUDED.tenant_id, @@ -98,6 +102,7 @@ func (s *PostgresStore) Upsert(ctx context.Context, state ResourceState) error { transition_time = EXCLUDED.transition_time, fulfillment_version = EXCLUDED.fulfillment_version, billing_dimensions = EXCLUDED.billing_dimensions, + component_billable_since = EXCLUDED.component_billable_since, updated_at = NOW() WHERE metering_resource_state.fulfillment_version <= EXCLUDED.fulfillment_version`, state.ResourceID, @@ -112,6 +117,7 @@ func (s *PostgresStore) Upsert(ctx context.Context, state ResourceState) error { state.TransitionTime, state.FulfillmentVersion, dimensions, + componentSince, ) if err != nil { return fmt.Errorf("upserting resource state %s: %w", state.ResourceID, err) @@ -135,7 +141,7 @@ func (s *PostgresStore) ListBillable(ctx context.Context) ([]ResourceState, erro SELECT resource_id, resource_type, tenant_id, project_id, current_state, previous_state, is_billable, billable_since, last_heartbeat_at, transition_time, fulfillment_version, - billing_dimensions + billing_dimensions, component_billable_since FROM metering_resource_state WHERE is_billable = TRUE`) if err != nil { @@ -150,7 +156,7 @@ func (s *PostgresStore) ListAll(ctx context.Context) ([]ResourceState, error) { SELECT resource_id, resource_type, tenant_id, project_id, current_state, previous_state, is_billable, billable_since, last_heartbeat_at, transition_time, fulfillment_version, - billing_dimensions + billing_dimensions, component_billable_since FROM metering_resource_state`) if err != nil { return nil, fmt.Errorf("querying all resources: %w", err) @@ -176,12 +182,13 @@ func (s *PostgresStore) UpdateLastHeartbeat(ctx context.Context, resourceIDs []s func scanResourceState(row pgx.Row) (*ResourceState, error) { var ( - state ResourceState - previousState *string - projectID *string - billableSince *time.Time - lastHeartbeat *time.Time - dimensionsJSON []byte + state ResourceState + previousState *string + projectID *string + billableSince *time.Time + lastHeartbeat *time.Time + dimensionsJSON []byte + componentSinceJSON []byte ) err := row.Scan( @@ -197,6 +204,7 @@ func scanResourceState(row pgx.Row) (*ResourceState, error) { &state.TransitionTime, &state.FulfillmentVersion, &dimensionsJSON, + &componentSinceJSON, ) if err != nil { return nil, err @@ -217,6 +225,12 @@ func scanResourceState(row pgx.Row) (*ResourceState, error) { } } + if len(componentSinceJSON) > 0 { + if err := json.Unmarshal(componentSinceJSON, &state.ComponentBillableSince); err != nil { + return nil, fmt.Errorf("unmarshaling component billable since: %w", err) + } + } + return &state, nil } diff --git a/osac-metering/metering-service/internal/projection/types.go b/osac-metering/metering-service/internal/projection/types.go index 8ead24b48..a119e608e 100644 --- a/osac-metering/metering-service/internal/projection/types.go +++ b/osac-metering/metering-service/internal/projection/types.go @@ -14,16 +14,23 @@ import ( ) type ResourceState struct { - ResourceID string - ResourceType string - TenantID string - ProjectID string - CurrentState string - PreviousState string - IsBillable bool - BillableSince *time.Time - LastHeartbeatAt *time.Time - TransitionTime time.Time - FulfillmentVersion int32 - BillingDimensions map[string]any + ResourceID string + ResourceType string + TenantID string + ProjectID string + CurrentState string + PreviousState string + IsBillable bool + BillableSince *time.Time + // ComponentBillableSince tracks, per node_set, when that component's + // billing dimensions last changed. N+1 decomposed resources (ClusterOrder) + // scale different node sets at different times — BillableSince alone + // would understate duration_seconds for a component that didn't cause + // the most recent reset. Keyed by ComponentRecord.NodeSet; absent for + // resource types that don't decompose into components. + ComponentBillableSince map[string]time.Time + LastHeartbeatAt *time.Time + TransitionTime time.Time + FulfillmentVersion int32 + BillingDimensions map[string]any } diff --git a/osac-metering/metering-service/internal/reconciliation/reconciler.go b/osac-metering/metering-service/internal/reconciliation/reconciler.go index 10778b3f2..19c5884b9 100644 --- a/osac-metering/metering-service/internal/reconciliation/reconciler.go +++ b/osac-metering/metering-service/internal/reconciliation/reconciler.go @@ -179,6 +179,7 @@ func (r *Reconciler) reconcileFulfillmentResources(ctx context.Context, fulfillm } if isBillable { newState.BillableSince = &now + newState.ComponentBillableSince = events.NextComponentBillableSince(nil, nil, fs.billingDimensions, now) } if err := r.store.Upsert(ctx, newState); err != nil { if errors.Is(err, projection.ErrStaleVersion) { @@ -227,9 +228,11 @@ func (r *Reconciler) reconcileFulfillmentResources(ctx context.Context, fulfillm ps.TransitionTime = now if isBillable && !wasBillable { ps.BillableSince = &now + ps.ComponentBillableSince = events.NextComponentBillableSince(nil, nil, fs.billingDimensions, now) } if !isBillable { ps.BillableSince = nil + ps.ComponentBillableSince = nil } if err := r.store.Upsert(ctx, ps); err != nil { if errors.Is(err, projection.ErrStaleVersion) { diff --git a/osac-metering/metering-service/internal/reconciliation/reconciler_test.go b/osac-metering/metering-service/internal/reconciliation/reconciler_test.go index 106c02a75..cbbce2ba5 100644 --- a/osac-metering/metering-service/internal/reconciliation/reconciler_test.go +++ b/osac-metering/metering-service/internal/reconciliation/reconciler_test.go @@ -656,7 +656,6 @@ var _ = Describe("Reconciler", func() { Describe("CaaS cluster reconciliation", func() { makeClusterProto := func(id, tenant string, state privatev1.ClusterState, version int32) *privatev1.Cluster { - versionName := "4.17.0" return &privatev1.Cluster{ Id: id, Metadata: &privatev1.Metadata{ @@ -664,8 +663,8 @@ var _ = Describe("Reconciler", func() { Version: version, }, Spec: &privatev1.ClusterSpec{ - Template: &privatev1.ClusterTemplateReference{Name: "ocp-ci-small"}, - VersionName: &versionName, + Template: &privatev1.ClusterTemplateReference{Name: "ocp-ci-small"}, + Version: &privatev1.ClusterVersionReference{Id: "4.17.0", Name: "4.17.0"}, NodeSets: map[string]*privatev1.ClusterNodeSet{ "gpu-workers": {HostType: &privatev1.HostTypeReference{Name: "gpu-h100"}, Size: 2}, }, diff --git a/osac-metering/metering-service/internal/watch/consumer.go b/osac-metering/metering-service/internal/watch/consumer.go index b6e34e3e7..8b4055383 100644 --- a/osac-metering/metering-service/internal/watch/consumer.go +++ b/osac-metering/metering-service/internal/watch/consumer.go @@ -305,12 +305,12 @@ func (c *Consumer) handleScalingEvent(ctx context.Context, event *privatev1.Even return nil } for _, comp := range changed { - scalingCtx := stateCtx - if comp.IsNew { - scalingCtx = &events.StateContext{ - PreviousState: stateCtx.PreviousState, - WasBillable: stateCtx.WasBillable, - } + scalingCtx := &events.StateContext{ + PreviousState: stateCtx.PreviousState, + WasBillable: stateCtx.WasBillable, + } + if !comp.IsNew { + scalingCtx.DurationSeconds = c.componentDurationSeconds(existing, comp.NodeSet, transitionTime) } ce, ceErr := c.buildScalingEvent( events.ComponentEventID(event.GetId(), comp), @@ -439,10 +439,38 @@ func (c *Consumer) buildProjectionState(mapper events.ResourceMapper, existing * } else { projState.BillableSince = existing.BillableSince } + var oldDims map[string]any + var oldSince map[string]time.Time + if existing != nil { + oldDims = existing.BillingDimensions + oldSince = existing.ComponentBillableSince + } + projState.ComponentBillableSince = events.NextComponentBillableSince(oldDims, oldSince, dims, tt) } return projState } +// componentDurationSeconds returns how long a component's prior billing +// dimensions were in effect. Returns nil if no per-component timestamp is +// recorded for nodeSet — an honest "unknown" (the same signal already used +// for a genuinely new component) rather than guessing via the resource-wide +// BillableSince, which would silently reintroduce a narrower version of the +// cross-component bug this exists to fix. The only path that can leave an +// entry missing is a Reconciler correction that hasn't been updated to +// maintain ComponentBillableSince (see events.NextComponentBillableSince +// callers in the reconciliation package) — logged so an unexpected rate of +// occurrence is debuggable rather than silently absorbed. +func (c *Consumer) componentDurationSeconds(existing *projection.ResourceState, nodeSet string, transitionTime time.Time) *float64 { + since, ok := existing.ComponentBillableSince[nodeSet] + if !ok { + c.logger.V(1).Info("no per-component billable-since recorded, reporting nil duration_seconds", + "resource_id", existing.ResourceID, "node_set", nodeSet) + return nil + } + duration := transitionTime.Sub(since).Seconds() + return &duration +} + func (c *Consumer) buildStateContext(existing *projection.ResourceState, nowBillable bool, transitionTime time.Time, newDims map[string]any) *events.StateContext { if existing == nil { return &events.StateContext{} diff --git a/osac-metering/metering-service/internal/watch/consumer_test.go b/osac-metering/metering-service/internal/watch/consumer_test.go index b32364ccb..9b36f83b0 100644 --- a/osac-metering/metering-service/internal/watch/consumer_test.go +++ b/osac-metering/metering-service/internal/watch/consumer_test.go @@ -983,7 +983,6 @@ var _ = Describe("Consumer", func() { Describe("CaaS Cluster events", func() { makeCluster := func(id, tenant string, state privatev1.ClusterState, nodeSets map[string]*privatev1.ClusterNodeSet) *privatev1.Cluster { - versionName := "4.17.0" return &privatev1.Cluster{ Id: id, Metadata: &privatev1.Metadata{ @@ -992,9 +991,9 @@ var _ = Describe("Consumer", func() { CreationTimestamp: timestamppb.Now(), }, Spec: &privatev1.ClusterSpec{ - Template: &privatev1.ClusterTemplateReference{Name: "ocp-ci-small"}, - VersionName: &versionName, - NodeSets: nodeSets, + Template: &privatev1.ClusterTemplateReference{Name: "ocp-ci-small"}, + Version: &privatev1.ClusterVersionReference{Id: "4.17.0", Name: "4.17.0"}, + NodeSets: nodeSets, }, Status: &privatev1.ClusterStatus{ State: state, @@ -1327,7 +1326,12 @@ var _ = Describe("Consumer", func() { BillableSince: &now, FulfillmentVersion: 1, BillingDimensions: clusterBillingDims(), - TransitionTime: now, + ComponentBillableSince: map[string]time.Time{ + "_control_plane": now, + "cpu-workers": now, + "gpu-workers": now, + }, + TransitionTime: now, } // Scale gpu-h100 from 2 to 4, cpu-only stays at 3 @@ -1439,6 +1443,10 @@ var _ = Describe("Consumer", func() { map[string]any{"node_set": "gpu-workers", "component": "worker", "host_type": "gpu-h100", "node_count": int32(2)}, }, }, + ComponentBillableSince: map[string]time.Time{ + "_control_plane": billableStart, + "gpu-workers": billableStart, + }, TransitionTime: billableStart, } @@ -1485,6 +1493,86 @@ var _ = Describe("Consumer", func() { "newly-added component should have nil duration_seconds (no prior interval)") }) + It("computes duration_seconds from each component's own last change, not the cluster-wide reset, across two sequential scaling events", func() { + store := newMockStore() + t0 := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + t1 := t0.Add(1 * time.Hour) + t2 := t0.Add(3 * time.Hour) + + store.states["cl-staggered"] = projection.ResourceState{ + ResourceID: "cl-staggered", + ResourceType: events.ResourceTypeClusterOrder, + TenantID: "tenant-1", + CurrentState: "READY", + IsBillable: true, + BillableSince: &t0, + FulfillmentVersion: 1, + BillingDimensions: clusterBillingDims(), + ComponentBillableSince: map[string]time.Time{ + "_control_plane": t0, + "cpu-workers": t0, + "gpu-workers": t0, + }, + TransitionTime: t0, + } + + // T1: cpu-workers scales 3->5, gpu-workers stays at 2 (unchanged since T0). + clAtT1 := makeCluster("cl-staggered", "tenant-1", privatev1.ClusterState_CLUSTER_STATE_READY, map[string]*privatev1.ClusterNodeSet{ + "cpu-workers": {HostType: &privatev1.HostTypeReference{Name: "cpu-only"}, Size: 5}, + "gpu-workers": {HostType: &privatev1.HostTypeReference{Name: "gpu-h100"}, Size: 2}, + }) + clAtT1.Status.StateTransitionTime = timestamppb.New(t1) + eventT1 := &privatev1.Event{ + Id: "evt-t1", + Type: privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, + Payload: &privatev1.Event_Cluster{Cluster: clAtT1}, + } + + // T2: gpu-workers scales 2->4, cpu-workers stays at 5 (unchanged since T1). + clAtT2 := makeCluster("cl-staggered", "tenant-1", privatev1.ClusterState_CLUSTER_STATE_READY, map[string]*privatev1.ClusterNodeSet{ + "cpu-workers": {HostType: &privatev1.HostTypeReference{Name: "cpu-only"}, Size: 5}, + "gpu-workers": {HostType: &privatev1.HostTypeReference{Name: "gpu-h100"}, Size: 4}, + }) + clAtT2.Status.StateTransitionTime = timestamppb.New(t2) + eventT2 := &privatev1.Event{ + Id: "evt-t2", + Type: privatev1.EventType_EVENT_TYPE_OBJECT_UPDATED, + Payload: &privatev1.Event_Cluster{Cluster: clAtT2}, + } + + stream := &mockWatchStream{ + responses: []*privatev1.EventsWatchResponse{makeResponse(eventT1), makeResponse(eventT2)}, + } + client.results = []mockStreamResult{{stream: stream}} + + pub := &mockPublisher{published: make([]cloudevents.Event, 0, 2), cancelFunc: cancel} + consumer := newConsumerWithStore(pub, store) + + err := consumer.Run(ctx) + Expect(err).ToNot(HaveOccurred()) + + pub.mu.Lock() + defer pub.mu.Unlock() + Expect(pub.published).To(HaveLen(2)) + + eventsByNodeSet := map[string]map[string]any{} + for _, e := range pub.published { + var data map[string]any + Expect(json.Unmarshal(e.Data(), &data)).To(Succeed()) + bd := data["billing_dimensions"].(map[string]any) + eventsByNodeSet[bd["node_set"].(string)] = data + } + + Expect(eventsByNodeSet).To(HaveKey("cpu-workers")) + Expect(eventsByNodeSet["cpu-workers"]["duration_seconds"]).To(BeNumerically("~", t1.Sub(t0).Seconds(), 1), + "cpu-workers' first-ever change should close a 1-hour interval since T0") + + Expect(eventsByNodeSet).To(HaveKey("gpu-workers")) + Expect(eventsByNodeSet["gpu-workers"]["duration_seconds"]).To(BeNumerically("~", t2.Sub(t0).Seconds(), 1), + "gpu-workers was unchanged since T0, so its closed interval must span T0->T2 (3 hours), "+ + "not T1->T2 (2 hours) from the cluster-wide reset caused by cpu-workers' unrelated change") + }) + It("advances projection version on same-state-same-dims update with higher version", func() { store := newMockStore() now := time.Now().UTC().Truncate(time.Microsecond)