From 756b6ea6b31924019d3ae46bf911d624069f60e7 Mon Sep 17 00:00:00 2001 From: Jin Wang Date: Mon, 3 Aug 2026 11:14:34 -0700 Subject: [PATCH] feat(flow): Prepare event rules for runtime resolution --- .../flow/internal/converter/dao/event_rule.go | 8 +- .../internal/converter/dao/event_rule_test.go | 40 ++ rest-api/flow/internal/eventrule/action.go | 10 +- .../flow/internal/eventrule/action_test.go | 54 ++- rest-api/flow/internal/eventrule/doc.go | 7 +- rest-api/flow/internal/eventrule/event.go | 27 +- .../internal/eventrule/manager/manager.go | 10 +- .../eventrule/manager/manager_test.go | 5 +- .../eventrule/policycodec/action_v1.go | 8 - .../eventrule/policycodec/codec_test.go | 12 + .../eventrule/processor/enrichment.go | 135 +++++++ .../eventrule/processor/enrichment_test.go | 208 +++++++++++ .../internal/eventrule/processor/errors.go | 36 ++ .../eventrule/processor/integration_test.go | 117 ++++++ .../internal/eventrule/processor/processor.go | 70 ++++ .../eventrule/processor/processor_test.go | 137 +++++++ rest-api/flow/internal/eventrule/store.go | 5 + .../inventory/resolver/component_type.go | 37 ++ .../inventory/resolver/component_type_test.go | 41 +++ .../internal/inventory/resolver/resolver.go | 216 +++++++++++ .../inventory/resolver/resolver_test.go | 341 ++++++++++++++++++ 21 files changed, 1479 insertions(+), 45 deletions(-) create mode 100644 rest-api/flow/internal/eventrule/processor/enrichment.go create mode 100644 rest-api/flow/internal/eventrule/processor/enrichment_test.go create mode 100644 rest-api/flow/internal/eventrule/processor/errors.go create mode 100644 rest-api/flow/internal/eventrule/processor/integration_test.go create mode 100644 rest-api/flow/internal/eventrule/processor/processor.go create mode 100644 rest-api/flow/internal/eventrule/processor/processor_test.go create mode 100644 rest-api/flow/internal/inventory/resolver/component_type.go create mode 100644 rest-api/flow/internal/inventory/resolver/component_type_test.go create mode 100644 rest-api/flow/internal/inventory/resolver/resolver.go create mode 100644 rest-api/flow/internal/inventory/resolver/resolver_test.go diff --git a/rest-api/flow/internal/converter/dao/event_rule.go b/rest-api/flow/internal/converter/dao/event_rule.go index 2a6c1bd28b..e255d11c4d 100644 --- a/rest-api/flow/internal/converter/dao/event_rule.go +++ b/rest-api/flow/internal/converter/dao/event_rule.go @@ -51,7 +51,11 @@ func EventRuleFrom(dbRule *dbmodel.EventRule) (*eventrule.Rule, error) { policy, err := policycodec.Unmarshal(dbRule.Policy) if err != nil { - return nil, err + return nil, fmt.Errorf( + "%w: decode policy: %w", + eventrule.ErrInvalidPersistedRule, + err, + ) } rule := &eventrule.Rule{ @@ -67,7 +71,7 @@ func EventRuleFrom(dbRule *dbmodel.EventRule) (*eventrule.Rule, error) { } if err := rule.Validate(); err != nil { - return nil, fmt.Errorf("decode persisted event rule: %w", err) + return nil, fmt.Errorf("%w: %w", eventrule.ErrInvalidPersistedRule, err) } return rule, nil diff --git a/rest-api/flow/internal/converter/dao/event_rule_test.go b/rest-api/flow/internal/converter/dao/event_rule_test.go index fc409ca565..39a22ad5a4 100644 --- a/rest-api/flow/internal/converter/dao/event_rule_test.go +++ b/rest-api/flow/internal/converter/dao/event_rule_test.go @@ -36,6 +36,46 @@ func TestEventRuleRoundTrip(t *testing.T) { require.Equal(t, rule, roundTripped) } +func TestEventRuleFromRejectsInvalidModel(t *testing.T) { + tests := map[string]struct { + mutate func(*dbmodel.EventRule) + wantMessage string + }{ + "invalid aggregate": { + mutate: func(rule *dbmodel.EventRule) { + rule.Name = "" + }, + wantMessage: "event rule name is empty", + }, + "invalid policy": { + mutate: func(rule *dbmodel.EventRule) { + rule.Policy = []byte(`{"version": 999}`) + }, + wantMessage: "decode policy", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + dbRule, err := EventRuleTo(&eventrule.Rule{ + ID: uuid.New(), + Origin: eventrule.RuleOriginPersisted, + Name: "test", + EventType: "test.event", + Policy: eventrule.Policy{Actions: []eventrule.Action{ + eventrule.NewAction("noop", eventrule.ActionCondition{}, eventrule.Noop{}), + }}, + }) + require.NoError(t, err) + test.mutate(dbRule) + + _, err = EventRuleFrom(dbRule) + require.ErrorIs(t, err, eventrule.ErrInvalidPersistedRule) + require.ErrorContains(t, err, test.wantMessage) + }) + } +} + func TestEventRuleBindingRoundTrip(t *testing.T) { scopes := map[string]eventrule.Scope{ "site": {Type: eventrule.ScopeTypeSite}, diff --git a/rest-api/flow/internal/eventrule/action.go b/rest-api/flow/internal/eventrule/action.go index 5c5d6643da..5d41ce74ec 100644 --- a/rest-api/flow/internal/eventrule/action.go +++ b/rest-api/flow/internal/eventrule/action.go @@ -54,15 +54,16 @@ func (c ActionCondition) validate() error { return nil } -// AppliesTo reports whether the condition accepts the envelope. -func (c ActionCondition) AppliesTo(envelope Envelope) bool { +// AppliesTo reports whether the condition accepts the envelope and its +// canonically resolved resource. +func (c ActionCondition) AppliesTo(envelope Envelope, resource ResolvedResource) bool { if c.Severities != nil && !slices.Contains(c.Severities, envelope.Severity) { return false } if c.ComponentTypes != nil && - !slices.Contains(c.ComponentTypes, envelope.Resource.ComponentType) { + !slices.Contains(c.ComponentTypes, resource.ComponentType) { return false } @@ -243,6 +244,9 @@ func (s SendAlert) validate() error { if err := s.Severity.Validate(); err != nil { return err } + if s.Severity.IsUnspecified() { + return fmt.Errorf("alert severity cannot be unspecified") + } return validateOptionalString("alert message", s.Message) } diff --git a/rest-api/flow/internal/eventrule/action_test.go b/rest-api/flow/internal/eventrule/action_test.go index eeeb55b03e..9356bad871 100644 --- a/rest-api/flow/internal/eventrule/action_test.go +++ b/rest-api/flow/internal/eventrule/action_test.go @@ -74,6 +74,9 @@ func TestActionRejectsInvalidDomainValues(t *testing.T) { "unspecified severity": NewAction( "noop", ActionCondition{Severities: []Severity{SeverityUnspecified}}, Noop{}, ), + "unspecified alert severity": NewAction( + "alert", ActionCondition{}, SendAlert{Severity: SeverityUnspecified}, + ), "unknown strategy": NewAction( "task", ActionCondition{}, unknownStrategySpec, ), @@ -120,19 +123,40 @@ func TestActionConditionAppliesTo(t *testing.T) { ComponentTypes: []flowtypes.ComponentType{flowtypes.ComponentTypeCompute}, } - assert.True(t, condition.AppliesTo(Envelope{ - Severity: SeverityCritical, - Resource: Resource{ComponentType: flowtypes.ComponentTypeCompute}, - })) - assert.False(t, condition.AppliesTo(Envelope{ - Severity: SeverityInfo, - Resource: Resource{ComponentType: flowtypes.ComponentTypeCompute}, - })) - assert.False(t, condition.AppliesTo(Envelope{ - Severity: SeverityCritical, - Resource: Resource{ComponentType: flowtypes.ComponentTypeNVSwitch}, - })) - assert.False(t, ActionCondition{Severities: []Severity{}}.AppliesTo(Envelope{ - Severity: SeverityCritical, - })) + tests := map[string]struct { + condition ActionCondition + envelope Envelope + resource ResolvedResource + want bool + }{ + "matches severity and component type": { + condition: condition, + envelope: Envelope{Severity: SeverityCritical}, + resource: ResolvedResource{ComponentType: flowtypes.ComponentTypeCompute}, + want: true, + }, + "rejects severity": { + condition: condition, + envelope: Envelope{Severity: SeverityInfo}, + resource: ResolvedResource{ComponentType: flowtypes.ComponentTypeCompute}, + }, + "rejects component type": { + condition: condition, + envelope: Envelope{Severity: SeverityCritical}, + resource: ResolvedResource{ComponentType: flowtypes.ComponentTypeNVSwitch}, + }, + "empty severity set matches nothing": { + condition: ActionCondition{Severities: []Severity{}}, + envelope: Envelope{Severity: SeverityCritical}, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + assert.Equal(t, test.want, test.condition.AppliesTo( + test.envelope, + test.resource, + )) + }) + } } diff --git a/rest-api/flow/internal/eventrule/doc.go b/rest-api/flow/internal/eventrule/doc.go index a310542a1b..0ff6958905 100644 --- a/rest-api/flow/internal/eventrule/doc.go +++ b/rest-api/flow/internal/eventrule/doc.go @@ -12,9 +12,10 @@ // Envelope is the normalized event accepted by processing. Its ID identifies // one event across delivery retries, while CorrelationKey groups distinct // observations of the same logical incident for optional semantic -// deduplication. Resource identifies the Flow rack or component concerned by -// the event. A resource may initially have only an ExternalID; enrichment can -// later populate its Flow ID and canonical component type. +// deduplication. Resource is the caller-supplied reference to the Flow rack or +// component concerned by the event and may contain only an ExternalID. +// ResolvedResource separately contains the canonical ID, rack ID, and component +// type established by processing so enrichment never mutates the envelope. // // Envelope.Payload is opaque JSON whose schema is selected by Envelope.Type. // The generic domain validates only that the payload is valid JSON. The child diff --git a/rest-api/flow/internal/eventrule/event.go b/rest-api/flow/internal/eventrule/event.go index 6f631b4af6..4506f10584 100644 --- a/rest-api/flow/internal/eventrule/event.go +++ b/rest-api/flow/internal/eventrule/event.go @@ -119,15 +119,13 @@ func (e *Envelope) Validate() error { // Resource identifies the resource an event is about. type Resource struct { - Kind ResourceKind - ExternalID string - // ID is the resolved Flow resource UUID. uuid.Nil means that the - // resource has not been resolved or is unavailable. - ID uuid.UUID - ComponentType flowtypes.ComponentType + Kind ResourceKind + ExternalID string + ID uuid.UUID + ComponentTypeHint flowtypes.ComponentType } -// Validate checks resource identity and enrichment. +// Validate checks the caller-supplied resource reference. func (r Resource) Validate() error { if err := r.Kind.Validate(); err != nil { return err @@ -137,15 +135,24 @@ func (r Resource) Validate() error { return err } - if r.ComponentType != "" { + if r.ComponentTypeHint != "" { if r.Kind != ResourceKindComponent { - return fmt.Errorf("resource component_type requires component kind") + return fmt.Errorf("resource component_type_hint requires component kind") } - if err := r.ComponentType.Validate(); err != nil { + if err := r.ComponentTypeHint.Validate(); err != nil { return err } } return nil } + +// ResolvedResource contains the canonical inventory identity and attributes +// established during event enrichment. +type ResolvedResource struct { + Kind ResourceKind + ID uuid.UUID + RackID uuid.UUID + ComponentType flowtypes.ComponentType +} diff --git a/rest-api/flow/internal/eventrule/manager/manager.go b/rest-api/flow/internal/eventrule/manager/manager.go index 2db6a1df1a..2fcac87151 100644 --- a/rest-api/flow/internal/eventrule/manager/manager.go +++ b/rest-api/flow/internal/eventrule/manager/manager.go @@ -253,7 +253,8 @@ func (m *Manager) Unbind(ctx context.Context, bindingID uuid.UUID) error { return m.bindings.Unbind(ctx, bindingID) } -// GetEffective resolves rack, site, then built-in precedence. +// GetEffective resolves rack, site, then built-in precedence. It returns +// (nil, nil) when no effective rule exists. func (m *Manager) GetEffective( ctx context.Context, eventType eventrule.Type, @@ -292,7 +293,12 @@ func (m *Manager) GetEffective( } // Use the immutable built-in when no persisted scope supplies a rule. - return m.builtIns.GetByEventType(ctx, eventType) + rule, err = m.builtIns.GetByEventType(ctx, eventType) + if errors.Is(err, eventrule.ErrRuleNotFound) { + return nil, nil + } + + return rule, err } func (m *Manager) getForScope( diff --git a/rest-api/flow/internal/eventrule/manager/manager_test.go b/rest-api/flow/internal/eventrule/manager/manager_test.go index d77dd8b39d..fa039ac24b 100644 --- a/rest-api/flow/internal/eventrule/manager/manager_test.go +++ b/rest-api/flow/internal/eventrule/manager/manager_test.go @@ -145,8 +145,9 @@ func TestManagerEffectiveRulePrecedence(t *testing.T) { require.NoError(t, err) assert.Equal(t, site.ID, rule.ID) - _, err = manager.GetEffective(context.Background(), "unknown.event", rackID) - require.ErrorIs(t, err, eventrule.ErrRuleNotFound) + rule, err = manager.GetEffective(context.Background(), "unknown.event", rackID) + require.NoError(t, err) + assert.Nil(t, rule) } func TestManagerRejectsMissingIDs(t *testing.T) { diff --git a/rest-api/flow/internal/eventrule/policycodec/action_v1.go b/rest-api/flow/internal/eventrule/policycodec/action_v1.go index 8bc3af0c7c..1d3b429ad5 100644 --- a/rest-api/flow/internal/eventrule/policycodec/action_v1.go +++ b/rest-api/flow/internal/eventrule/policycodec/action_v1.go @@ -102,13 +102,6 @@ func unmarshalActionV1(data json.RawMessage) (eventrule.Action, error) { ) } - if decodedSeverity.IsUnspecified() { - return eventrule.Action{}, fmt.Errorf( - "condition severities[%d] cannot be unspecified", - i, - ) - } - severities[i] = decodedSeverity } @@ -191,7 +184,6 @@ func unmarshalActionSpecV1( if err != nil { return nil, fmt.Errorf("decode send_alert action spec v1 severity: %w", err) } - return eventrule.SendAlert{ Severity: severity, Message: persisted.Message, diff --git a/rest-api/flow/internal/eventrule/policycodec/codec_test.go b/rest-api/flow/internal/eventrule/policycodec/codec_test.go index 3edbfa8c2b..b3f4415842 100644 --- a/rest-api/flow/internal/eventrule/policycodec/codec_test.go +++ b/rest-api/flow/internal/eventrule/policycodec/codec_test.go @@ -107,6 +107,18 @@ func TestPolicyRejectsUnknownVersionsAndFields(t *testing.T) { } ] }`, + "unspecified send alert severity": `{ + "version":1, + "actions":[ + { + "version":1, + "id":"alert", + "type":"send_alert", + "condition":{}, + "spec":{"severity":""} + } + ] + }`, } for name, data := range tests { diff --git a/rest-api/flow/internal/eventrule/processor/enrichment.go b/rest-api/flow/internal/eventrule/processor/enrichment.go new file mode 100644 index 0000000000..fdc9c8360b --- /dev/null +++ b/rest-api/flow/internal/eventrule/processor/enrichment.go @@ -0,0 +1,135 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package processor prepares and processes event-rule envelopes. +package processor + +import ( + "context" + "fmt" + + "github.com/NVIDIA/infra-controller/rest-api/flow/internal/eventrule" + inventoryresolver "github.com/NVIDIA/infra-controller/rest-api/flow/internal/inventory/resolver" + "github.com/NVIDIA/infra-controller/rest-api/flow/pkg/common/devicetypes" + "github.com/NVIDIA/infra-controller/rest-api/flow/pkg/inventoryobjects/component" + "github.com/NVIDIA/infra-controller/rest-api/flow/pkg/inventoryobjects/rack" + "github.com/google/uuid" +) + +// enrichment contains the canonical resource information needed by processing. +type enrichment struct { + ResolvedResource eventrule.ResolvedResource +} + +// enrich coordinates all enrichment applied to an event envelope. +func (p *Processor) enrich( + ctx context.Context, + envelope eventrule.Envelope, +) (enrichment, error) { + resolvedResource, err := p.enrichResource(ctx, envelope.Resource) + if err != nil { + return enrichment{}, err + } + + return enrichment{ResolvedResource: resolvedResource}, nil +} + +// enrichResource resolves a resource's Flow identity, rack, and component type. +func (p *Processor) enrichResource( + ctx context.Context, + resource eventrule.Resource, +) (eventrule.ResolvedResource, error) { + switch resource.Kind { + case eventrule.ResourceKindRack: + return p.enrichRackResource(ctx, resource) + case eventrule.ResourceKindComponent: + return p.enrichComponentResource(ctx, resource) + default: + return eventrule.ResolvedResource{}, terminalError(fmt.Errorf( + "unsupported resource kind %q", + resource.Kind, + )) + } +} + +func (p *Processor) enrichRackResource( + ctx context.Context, + resource eventrule.Resource, +) (eventrule.ResolvedResource, error) { + resolved, err := p.resolveRack(ctx, resource) + if err != nil { + return eventrule.ResolvedResource{}, classifyInventoryError(err) + } + + return eventrule.ResolvedResource{ + Kind: eventrule.ResourceKindRack, + ID: resolved.Info.ID, + RackID: resolved.Info.ID, + }, nil +} + +func (p *Processor) enrichComponentResource( + ctx context.Context, + resource eventrule.Resource, +) (eventrule.ResolvedResource, error) { + resolved, err := p.resolveComponent(ctx, resource) + if err != nil { + return eventrule.ResolvedResource{}, classifyInventoryError(err) + } + + if resolved.RackID == uuid.Nil { + return eventrule.ResolvedResource{}, terminalError(fmt.Errorf( + "component %s has no resolved rack", + resolved.Info.ID, + )) + } + + resolvedType, err := inventoryresolver.ComponentTypeToFlow(resolved.Type) + if err != nil { + return eventrule.ResolvedResource{}, terminalError(fmt.Errorf( + "component %s type: %w", + resolved.Info.ID, + err, + )) + } + + return eventrule.ResolvedResource{ + Kind: eventrule.ResourceKindComponent, + ID: resolved.Info.ID, + RackID: resolved.RackID, + ComponentType: resolvedType, + }, nil +} + +func (p *Processor) resolveRack( + ctx context.Context, + resource eventrule.Resource, +) (*rack.Rack, error) { + if resource.ID != uuid.Nil { + return p.inventory.RackByID(ctx, resource.ID, false) + } + + return p.inventory.RackByName(ctx, resource.ExternalID, false) +} + +func (p *Processor) resolveComponent( + ctx context.Context, + resource eventrule.Resource, +) (*component.Component, error) { + if resource.ID != uuid.Nil { + return p.inventory.ComponentByID(ctx, resource.ID) + } + + componentType := devicetypes.ComponentTypeUnknown + if resource.ComponentTypeHint != "" { + componentType = devicetypes.ComponentTypeFromString( + string(resource.ComponentTypeHint), + ) + } + + return p.inventory.ComponentByExternalID( + ctx, + resource.ExternalID, + componentType, + ) +} diff --git a/rest-api/flow/internal/eventrule/processor/enrichment_test.go b/rest-api/flow/internal/eventrule/processor/enrichment_test.go new file mode 100644 index 0000000000..2fd8e80e9c --- /dev/null +++ b/rest-api/flow/internal/eventrule/processor/enrichment_test.go @@ -0,0 +1,208 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package processor + +import ( + "context" + "errors" + "testing" + + "github.com/NVIDIA/infra-controller/rest-api/flow/internal/eventrule" + inventoryresolver "github.com/NVIDIA/infra-controller/rest-api/flow/internal/inventory/resolver" + identifier "github.com/NVIDIA/infra-controller/rest-api/flow/pkg/common/Identifier" + "github.com/NVIDIA/infra-controller/rest-api/flow/pkg/common/deviceinfo" + "github.com/NVIDIA/infra-controller/rest-api/flow/pkg/common/devicetypes" + "github.com/NVIDIA/infra-controller/rest-api/flow/pkg/common/location" + "github.com/NVIDIA/infra-controller/rest-api/flow/pkg/inventoryobjects/component" + "github.com/NVIDIA/infra-controller/rest-api/flow/pkg/inventoryobjects/rack" + flowtypes "github.com/NVIDIA/infra-controller/rest-api/flow/pkg/types" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestEnrichComponent(t *testing.T) { + componentID := uuid.New() + rackID := uuid.New() + resolved := component.New( + devicetypes.ComponentTypeCompute, + &deviceinfo.DeviceInfo{ID: componentID}, + "", + nil, + ) + resolved.RackID = rackID + processor := New( + inventoryresolver.New(&processorInventory{ + components: []*component.Component{&resolved}, + }), + nil, + ) + + result, err := processor.enrich( + context.Background(), + validEnvelope(eventrule.Resource{ + Kind: eventrule.ResourceKindComponent, + ExternalID: "component-1", + }), + ) + require.NoError(t, err) + require.Equal(t, componentID, result.ResolvedResource.ID) + require.Equal(t, flowtypes.ComponentTypeCompute, result.ResolvedResource.ComponentType) + require.Equal(t, rackID, result.ResolvedResource.RackID) +} + +func TestEnrichClassifiesFailures(t *testing.T) { + inventoryErr := errors.New("inventory unavailable") + componentID := uuid.New() + componentWithoutRack := component.New( + devicetypes.ComponentTypeCompute, + &deviceinfo.DeviceInfo{ID: componentID}, + "", + nil, + ) + componentWithInvalidType := component.New( + devicetypes.ComponentType(100), + &deviceinfo.DeviceInfo{ID: componentID}, + "", + nil, + ) + componentWithInvalidType.RackID = uuid.New() + + tests := map[string]struct { + resource eventrule.Resource + inventory *processorInventory + wantErr error + wantMessage string + notTerminal bool + }{ + "missing identity is terminal": { + resource: eventrule.Resource{Kind: eventrule.ResourceKindComponent}, + inventory: &processorInventory{}, + wantErr: ErrTerminal, + }, + "inventory failure is retryable": { + resource: eventrule.Resource{ + Kind: eventrule.ResourceKindComponent, + ExternalID: "component-1", + }, + inventory: &processorInventory{err: inventoryErr}, + wantErr: inventoryErr, + notTerminal: true, + }, + "inventory not found is terminal": { + resource: eventrule.Resource{ + Kind: eventrule.ResourceKindComponent, + ExternalID: "component-1", + }, + inventory: &processorInventory{ + err: status.Error(codes.NotFound, "component not found"), + }, + wantErr: ErrTerminal, + }, + "component without rack is terminal": { + resource: eventrule.Resource{ + Kind: eventrule.ResourceKindComponent, + ExternalID: "component-1", + }, + inventory: &processorInventory{ + components: []*component.Component{&componentWithoutRack}, + }, + wantErr: ErrTerminal, + wantMessage: "has no resolved rack", + }, + "invalid component type is terminal": { + resource: eventrule.Resource{ + Kind: eventrule.ResourceKindComponent, + ExternalID: "component-1", + }, + inventory: &processorInventory{ + components: []*component.Component{&componentWithInvalidType}, + }, + wantErr: ErrTerminal, + wantMessage: "unknown inventory component type", + }, + "unsupported resource kind is terminal": { + resource: eventrule.Resource{Kind: eventrule.ResourceKind("unsupported")}, + inventory: &processorInventory{}, + wantErr: ErrTerminal, + wantMessage: "unsupported resource kind", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + processor := New(inventoryresolver.New(test.inventory), nil) + _, err := processor.enrich( + context.Background(), + validEnvelope(test.resource), + ) + require.ErrorIs(t, err, test.wantErr) + if test.wantMessage != "" { + require.ErrorContains(t, err, test.wantMessage) + } + if test.notTerminal { + require.NotErrorIs(t, err, ErrTerminal) + } + }) + } +} + +func TestEnrichRackUsesResolvedResourceAsRack(t *testing.T) { + rackID := uuid.New() + processor := New( + inventoryresolver.New(&processorInventory{ + rack: rack.New(deviceinfo.DeviceInfo{ID: rackID}, location.Location{}), + }), + nil, + ) + + result, err := processor.enrich( + context.Background(), + validEnvelope(eventrule.Resource{ + Kind: eventrule.ResourceKindRack, + ExternalID: "rack-1", + }), + ) + require.NoError(t, err) + require.Equal(t, rackID, result.ResolvedResource.ID) + require.Equal(t, rackID, result.ResolvedResource.RackID) +} + +func validEnvelope(resource eventrule.Resource) eventrule.Envelope { + return eventrule.Envelope{ + ID: uuid.New(), + Type: "test.event", + Resource: resource, + } +} + +type processorInventory struct { + component *component.Component + components []*component.Component + rack *rack.Rack + err error +} + +func (f *processorInventory) GetComponentByID( + context.Context, + uuid.UUID, +) (*component.Component, error) { + return f.component, f.err +} + +func (f *processorInventory) GetComponentsByExternalIDs( + context.Context, + []string, +) ([]*component.Component, error) { + return f.components, f.err +} + +func (f *processorInventory) GetRackByIdentifier( + context.Context, + identifier.Identifier, + bool, +) (*rack.Rack, error) { + return f.rack, f.err +} diff --git a/rest-api/flow/internal/eventrule/processor/errors.go b/rest-api/flow/internal/eventrule/processor/errors.go new file mode 100644 index 0000000000..92ce0212ec --- /dev/null +++ b/rest-api/flow/internal/eventrule/processor/errors.go @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package processor + +import ( + "errors" + "fmt" + + "github.com/NVIDIA/infra-controller/rest-api/flow/internal/eventrule" + inventoryresolver "github.com/NVIDIA/infra-controller/rest-api/flow/internal/inventory/resolver" +) + +// ErrTerminal identifies event-processing failures that cannot succeed on +// retry without changing the input or persisted state. +var ErrTerminal = errors.New("terminal event processing error") + +func classifyInventoryError(err error) error { + if errors.Is(err, inventoryresolver.ErrUnresolvable) { + return terminalError(err) + } + + return err +} + +func classifyRuleError(err error) error { + if errors.Is(err, eventrule.ErrInvalidPersistedRule) { + return terminalError(err) + } + + return err +} + +func terminalError(err error) error { + return fmt.Errorf("%w: %w", ErrTerminal, err) +} diff --git a/rest-api/flow/internal/eventrule/processor/integration_test.go b/rest-api/flow/internal/eventrule/processor/integration_test.go new file mode 100644 index 0000000000..6854a01471 --- /dev/null +++ b/rest-api/flow/internal/eventrule/processor/integration_test.go @@ -0,0 +1,117 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package processor + +import ( + "context" + "testing" + + converterdao "github.com/NVIDIA/infra-controller/rest-api/flow/internal/converter/dao" + dbmodel "github.com/NVIDIA/infra-controller/rest-api/flow/internal/db/model" + "github.com/NVIDIA/infra-controller/rest-api/flow/internal/eventrule" + "github.com/NVIDIA/infra-controller/rest-api/flow/internal/eventrule/manager" + "github.com/NVIDIA/infra-controller/rest-api/flow/internal/eventrule/registry" + "github.com/NVIDIA/infra-controller/rest-api/flow/internal/eventrule/store/memory" + inventoryresolver "github.com/NVIDIA/infra-controller/rest-api/flow/internal/inventory/resolver" + "github.com/NVIDIA/infra-controller/rest-api/flow/pkg/common/deviceinfo" + "github.com/NVIDIA/infra-controller/rest-api/flow/pkg/common/location" + "github.com/NVIDIA/infra-controller/rest-api/flow/pkg/inventoryobjects/rack" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestProcessorPreparationIntegration(t *testing.T) { + ctx := context.Background() + eventType := eventrule.Type("test.event") + rackID := uuid.New() + builtIn := processorRule(uuid.New(), eventrule.RuleOriginBuiltIn, eventType) + builtIns, err := registry.New(builtIn) + require.NoError(t, err) + store := memory.New() + persisted := &malformedRuleStore{Store: store} + ruleManager, err := manager.New(builtIns, persisted, store) + require.NoError(t, err) + + rackRule, err := ruleManager.Create(ctx, processorCreate(eventType, "rack")) + require.NoError(t, err) + _, err = ruleManager.Bind(ctx, rackRule.ID, eventrule.Scope{ + Type: eventrule.ScopeTypeRack, + ID: rackID, + }) + require.NoError(t, err) + + processor := New( + inventoryresolver.New(&processorInventory{ + rack: rack.New(deviceinfo.DeviceInfo{ID: rackID}, location.Location{}), + }), + ruleManager, + ) + envelope := eventrule.Envelope{ + ID: uuid.New(), + Type: eventType, + Resource: eventrule.Resource{Kind: eventrule.ResourceKindRack, ID: rackID}, + } + + prepared, err := processor.prepare(ctx, envelope) + require.NoError(t, err) + require.Equal(t, rackID, prepared.Enriched.ResolvedResource.RackID) + require.Equal(t, builtIn.ID, prepared.Rule.ID) + + require.NoError(t, ruleManager.SetEnabled(ctx, rackRule.ID, true)) + prepared, err = processor.prepare(ctx, envelope) + require.NoError(t, err) + require.Equal(t, rackRule.ID, prepared.Rule.ID) + + persisted.malformedID = rackRule.ID + _, err = processor.prepare(ctx, envelope) + require.ErrorIs(t, err, ErrTerminal) + require.ErrorIs(t, err, eventrule.ErrInvalidPersistedRule) +} + +type malformedRuleStore struct { + *memory.Store + malformedID uuid.UUID +} + +func (s *malformedRuleStore) GetByID( + ctx context.Context, + id uuid.UUID, +) (*eventrule.Rule, error) { + if id == s.malformedID { + return converterdao.EventRuleFrom(&dbmodel.EventRule{ + ID: id, + Name: "malformed", + EventType: "test.event", + Policy: []byte(`{"version": 999}`), + }) + } + return s.Store.GetByID(ctx, id) +} + +func processorCreate(eventType eventrule.Type, name string) eventrule.RuleCreate { + return eventrule.RuleCreate{ + Metadata: eventrule.RuleMetadata{Name: name}, + EventType: eventType, + Policy: eventrule.Policy{Actions: []eventrule.Action{ + eventrule.NewAction("noop", eventrule.ActionCondition{}, eventrule.Noop{}), + }}, + } +} + +func processorRule( + id uuid.UUID, + origin eventrule.RuleOrigin, + eventType eventrule.Type, +) *eventrule.Rule { + return &eventrule.Rule{ + ID: id, + Origin: origin, + Name: "built-in", + Enabled: true, + EventType: eventType, + Policy: eventrule.Policy{Actions: []eventrule.Action{ + eventrule.NewAction("noop", eventrule.ActionCondition{}, eventrule.Noop{}), + }}, + } +} diff --git a/rest-api/flow/internal/eventrule/processor/processor.go b/rest-api/flow/internal/eventrule/processor/processor.go new file mode 100644 index 0000000000..261d606bcc --- /dev/null +++ b/rest-api/flow/internal/eventrule/processor/processor.go @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package processor + +import ( + "context" + + "github.com/NVIDIA/infra-controller/rest-api/flow/internal/eventrule" + inventoryresolver "github.com/NVIDIA/infra-controller/rest-api/flow/internal/inventory/resolver" + "github.com/google/uuid" +) + +// effectiveRuleResolver resolves hierarchical rule precedence. A cache may +// decorate this interface without changing preparation or processing. +type effectiveRuleResolver interface { + GetEffective(context.Context, eventrule.Type, uuid.UUID) (*eventrule.Rule, error) +} + +// preparedEvent contains runtime inputs prepared for policy evaluation. +type preparedEvent struct { + Envelope eventrule.Envelope + Enriched enrichment + Rule *eventrule.Rule +} + +// Processor orchestrates event enrichment, rule selection, and processing. +type Processor struct { + inventory *inventoryresolver.Resolver + rules effectiveRuleResolver +} + +// New constructs an event processor. +func New( + inventory *inventoryresolver.Resolver, + rules effectiveRuleResolver, +) *Processor { + return &Processor{inventory: inventory, rules: rules} +} + +// prepare enriches an envelope and resolves its effective rule. An absent rule +// is an accepted no-op represented by a nil preparedEvent.Rule. +func (p *Processor) prepare( + ctx context.Context, + envelope eventrule.Envelope, +) (preparedEvent, error) { + if err := envelope.Validate(); err != nil { + return preparedEvent{}, terminalError(err) + } + + enriched, err := p.enrich(ctx, envelope) + if err != nil { + return preparedEvent{}, err + } + + rule, err := p.rules.GetEffective( + ctx, + envelope.Type, + enriched.ResolvedResource.RackID, + ) + if err != nil { + return preparedEvent{}, classifyRuleError(err) + } + + return preparedEvent{ + Envelope: envelope, + Enriched: enriched, + Rule: rule, + }, nil +} diff --git a/rest-api/flow/internal/eventrule/processor/processor_test.go b/rest-api/flow/internal/eventrule/processor/processor_test.go new file mode 100644 index 0000000000..881596c6eb --- /dev/null +++ b/rest-api/flow/internal/eventrule/processor/processor_test.go @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package processor + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/NVIDIA/infra-controller/rest-api/flow/internal/eventrule" + inventoryresolver "github.com/NVIDIA/infra-controller/rest-api/flow/internal/inventory/resolver" + "github.com/NVIDIA/infra-controller/rest-api/flow/pkg/common/deviceinfo" + "github.com/NVIDIA/infra-controller/rest-api/flow/pkg/common/location" + "github.com/NVIDIA/infra-controller/rest-api/flow/pkg/inventoryobjects/rack" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestPrepare(t *testing.T) { + rackID := uuid.New() + rule := &eventrule.Rule{ID: uuid.New()} + storeErr := errors.New("rule store unavailable") + tests := map[string]struct { + envelope eventrule.Envelope + rule *eventrule.Rule + ruleErr error + wantErr error + wantMessage string + wantTerminal bool + wantResolved bool + }{ + "effective rule found": { + rule: rule, + wantResolved: true, + }, + "effective rule absent": { + wantResolved: true, + }, + "store failure is retryable": { + ruleErr: storeErr, + wantErr: storeErr, + wantResolved: true, + }, + "invalid persisted rule is terminal": { + ruleErr: fmt.Errorf( + "decode persisted override: %w", + eventrule.ErrInvalidPersistedRule, + ), + wantErr: eventrule.ErrInvalidPersistedRule, + wantTerminal: true, + wantResolved: true, + }, + "invalid envelope is terminal": { + envelope: eventrule.Envelope{}, + wantErr: ErrTerminal, + wantMessage: "event id is required", + wantTerminal: true, + wantResolved: false, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + envelope := test.envelope + if test.wantResolved { + envelope = eventrule.Envelope{ + ID: uuid.New(), + Type: "test.event", + Resource: eventrule.Resource{Kind: eventrule.ResourceKindRack, ID: rackID}, + } + } + + resolverCalled := false + resolver := effectiveRuleResolverFunc(func( + _ context.Context, + eventType eventrule.Type, + resolvedRackID uuid.UUID, + ) (*eventrule.Rule, error) { + resolverCalled = true + require.Equal(t, eventrule.Type("test.event"), eventType) + require.Equal(t, rackID, resolvedRackID) + return test.rule, test.ruleErr + }) + processor := newRackProcessor(rackID, resolver) + + result, err := processor.prepare( + context.Background(), + envelope, + ) + require.Equal(t, test.wantResolved, resolverCalled) + if test.wantErr == nil { + require.NoError(t, err) + require.Equal(t, rackID, result.Enriched.ResolvedResource.RackID) + require.Equal(t, test.rule, result.Rule) + return + } + + require.ErrorIs(t, err, test.wantErr) + if test.wantMessage != "" { + require.ErrorContains(t, err, test.wantMessage) + } + if test.wantTerminal { + require.ErrorIs(t, err, ErrTerminal) + } else { + require.NotErrorIs(t, err, ErrTerminal) + } + }) + } +} + +func newRackProcessor( + rackID uuid.UUID, + rules effectiveRuleResolver, +) *Processor { + return New( + inventoryresolver.New(&processorInventory{ + rack: rack.New(deviceinfo.DeviceInfo{ID: rackID}, location.Location{}), + }), + rules, + ) +} + +type effectiveRuleResolverFunc func( + context.Context, + eventrule.Type, + uuid.UUID, +) (*eventrule.Rule, error) + +func (f effectiveRuleResolverFunc) GetEffective( + ctx context.Context, + eventType eventrule.Type, + rackID uuid.UUID, +) (*eventrule.Rule, error) { + return f(ctx, eventType, rackID) +} diff --git a/rest-api/flow/internal/eventrule/store.go b/rest-api/flow/internal/eventrule/store.go index bd56f28132..73996fe309 100644 --- a/rest-api/flow/internal/eventrule/store.go +++ b/rest-api/flow/internal/eventrule/store.go @@ -13,6 +13,11 @@ import ( // ErrRuleNotFound identifies an unsuccessful rule lookup. var ErrRuleNotFound = errors.New("event rule not found") +// ErrInvalidPersistedRule identifies persisted rule data that cannot be +// decoded into a valid domain rule. Retrying without repairing the stored data +// cannot succeed. +var ErrInvalidPersistedRule = errors.New("invalid persisted event rule") + // RuleFilter limits rules returned by a store. type RuleFilter struct { EventType *Type diff --git a/rest-api/flow/internal/inventory/resolver/component_type.go b/rest-api/flow/internal/inventory/resolver/component_type.go new file mode 100644 index 0000000000..2e119ec2fc --- /dev/null +++ b/rest-api/flow/internal/inventory/resolver/component_type.go @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package resolver + +import ( + "fmt" + + "github.com/NVIDIA/infra-controller/rest-api/flow/pkg/common/devicetypes" + flowtypes "github.com/NVIDIA/infra-controller/rest-api/flow/pkg/types" +) + +// ComponentTypeToFlow converts an inventory component type into its canonical +// public Flow representation. +func ComponentTypeToFlow( + componentType devicetypes.ComponentType, +) (flowtypes.ComponentType, error) { + switch componentType { + case devicetypes.ComponentTypeCompute: + return flowtypes.ComponentTypeCompute, nil + case devicetypes.ComponentTypeNVSwitch: + return flowtypes.ComponentTypeNVSwitch, nil + case devicetypes.ComponentTypePowerShelf: + return flowtypes.ComponentTypePowerShelf, nil + case devicetypes.ComponentTypeToRSwitch: + return flowtypes.ComponentTypeTORSwitch, nil + case devicetypes.ComponentTypeUMS: + return flowtypes.ComponentTypeUMS, nil + case devicetypes.ComponentTypeCDU: + return flowtypes.ComponentTypeCDU, nil + default: + return flowtypes.ComponentTypeUnknown, fmt.Errorf( + "unknown inventory component type %d", + componentType, + ) + } +} diff --git a/rest-api/flow/internal/inventory/resolver/component_type_test.go b/rest-api/flow/internal/inventory/resolver/component_type_test.go new file mode 100644 index 0000000000..2215703bb4 --- /dev/null +++ b/rest-api/flow/internal/inventory/resolver/component_type_test.go @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package resolver + +import ( + "testing" + + "github.com/NVIDIA/infra-controller/rest-api/flow/pkg/common/devicetypes" + flowtypes "github.com/NVIDIA/infra-controller/rest-api/flow/pkg/types" + "github.com/stretchr/testify/require" +) + +func TestComponentTypeToFlow(t *testing.T) { + tests := map[string]struct { + componentType devicetypes.ComponentType + want flowtypes.ComponentType + wantErr bool + }{ + "compute": {devicetypes.ComponentTypeCompute, flowtypes.ComponentTypeCompute, false}, + "NVSwitch": {devicetypes.ComponentTypeNVSwitch, flowtypes.ComponentTypeNVSwitch, false}, + "power shelf": {devicetypes.ComponentTypePowerShelf, flowtypes.ComponentTypePowerShelf, false}, + "ToR switch": {devicetypes.ComponentTypeToRSwitch, flowtypes.ComponentTypeTORSwitch, false}, + "UMS": {devicetypes.ComponentTypeUMS, flowtypes.ComponentTypeUMS, false}, + "CDU": {devicetypes.ComponentTypeCDU, flowtypes.ComponentTypeCDU, false}, + "unknown": {devicetypes.ComponentTypeUnknown, flowtypes.ComponentTypeUnknown, true}, + "invalid": {devicetypes.ComponentType(100), flowtypes.ComponentTypeUnknown, true}, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + got, err := ComponentTypeToFlow(test.componentType) + if test.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + require.Equal(t, test.want, got) + }) + } +} diff --git a/rest-api/flow/internal/inventory/resolver/resolver.go b/rest-api/flow/internal/inventory/resolver/resolver.go new file mode 100644 index 0000000000..513e6ed219 --- /dev/null +++ b/rest-api/flow/internal/inventory/resolver/resolver.go @@ -0,0 +1,216 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package resolver normalizes inventory resource references into canonical +// Flow inventory objects. +package resolver + +import ( + "context" + "errors" + "fmt" + + identifier "github.com/NVIDIA/infra-controller/rest-api/flow/pkg/common/Identifier" + "github.com/NVIDIA/infra-controller/rest-api/flow/pkg/common/devicetypes" + "github.com/NVIDIA/infra-controller/rest-api/flow/pkg/inventoryobjects/component" + "github.com/NVIDIA/infra-controller/rest-api/flow/pkg/inventoryobjects/rack" + "github.com/google/uuid" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// ErrUnresolvable identifies an invalid, missing, ambiguous, or malformed +// inventory resource. Retrying the same reference without an inventory change +// cannot resolve it. +var ErrUnresolvable = errors.New("inventory resource cannot be resolved") + +// InventoryReader is the lookup capability required by Resolver. Its method +// shapes intentionally align with the underlying inventory manager and store +// implementations so they can satisfy this interface without adapters. +type InventoryReader interface { + GetComponentByID(context.Context, uuid.UUID) (*component.Component, error) + GetComponentsByExternalIDs(context.Context, []string) ([]*component.Component, error) + GetRackByIdentifier(context.Context, identifier.Identifier, bool) (*rack.Rack, error) +} + +// Resolver performs canonical inventory identity resolution. +type Resolver struct { + inventory InventoryReader +} + +// New constructs an inventory resource resolver. +func New(inventory InventoryReader) *Resolver { + return &Resolver{inventory: inventory} +} + +// ComponentByID returns the canonical component for one Flow UUID. +func (r *Resolver) ComponentByID( + ctx context.Context, + id uuid.UUID, +) (*component.Component, error) { + if id == uuid.Nil { + return nil, unresolvableError("component id is required") + } + + reference := fmt.Sprintf("component id %s", id) + resolved, err := r.inventory.GetComponentByID(ctx, id) + if err != nil { + return nil, classifyLookupError(reference, err) + } + + resolved, err = validateComponent(resolved, reference) + if err != nil { + return nil, err + } + if resolved.Info.ID != id { + return nil, unresolvableError( + "%s resolved to component id %s", + reference, + resolved.Info.ID, + ) + } + + return resolved, nil +} + +// ComponentByExternalID returns the single canonical component matching an +// external ID and optional type. An unknown type requires the ID to resolve +// unambiguously across all component types. +func (r *Resolver) ComponentByExternalID( + ctx context.Context, + externalID string, + componentType devicetypes.ComponentType, +) (*component.Component, error) { + if externalID == "" { + return nil, unresolvableError("component external id is required") + } + + reference := fmt.Sprintf("component external id %q", externalID) + components, err := r.inventory.GetComponentsByExternalIDs( + ctx, + []string{externalID}, + ) + if err != nil { + return nil, classifyLookupError(reference, err) + } + + selected, err := selectComponent(components, componentType) + if err != nil { + return nil, fmt.Errorf("%s: %w", reference, err) + } + + return validateComponent(selected, reference) +} + +func selectComponent( + candidates []*component.Component, + componentType devicetypes.ComponentType, +) (*component.Component, error) { + matches := make([]*component.Component, 0, len(candidates)) + for _, candidate := range candidates { + if candidate == nil { + continue + } + if componentType == devicetypes.ComponentTypeUnknown || candidate.Type == componentType { + matches = append(matches, candidate) + } + } + + if len(matches) == 1 { + return matches[0], nil + } + + return nil, unresolvableError("%d matching components", len(matches)) +} + +// RackByID returns the canonical rack for one Flow UUID. +func (r *Resolver) RackByID( + ctx context.Context, + id uuid.UUID, + withComponents bool, +) (*rack.Rack, error) { + if id == uuid.Nil { + return nil, unresolvableError("rack id is required") + } + + return r.rackByIdentifier( + ctx, + identifier.Identifier{ID: id}, + withComponents, + fmt.Sprintf("rack id %s", id), + ) +} + +// RackByName returns the canonical rack for one inventory name. +func (r *Resolver) RackByName( + ctx context.Context, + name string, + withComponents bool, +) (*rack.Rack, error) { + if name == "" { + return nil, unresolvableError("rack name is required") + } + + return r.rackByIdentifier( + ctx, + identifier.Identifier{Name: name}, + withComponents, + fmt.Sprintf("rack name %q", name), + ) +} + +func (r *Resolver) rackByIdentifier( + ctx context.Context, + ref identifier.Identifier, + withComponents bool, + reference string, +) (*rack.Rack, error) { + resolved, err := r.inventory.GetRackByIdentifier( + ctx, + ref, + withComponents, + ) + if err != nil { + return nil, classifyLookupError(reference, err) + } + + if resolved == nil || resolved.Info.ID == uuid.Nil { + return nil, unresolvableError("%s has no canonical id", reference) + } + if ref.ID != uuid.Nil && resolved.Info.ID != ref.ID { + return nil, unresolvableError( + "%s resolved to rack id %s", + reference, + resolved.Info.ID, + ) + } + + return resolved, nil +} + +func classifyLookupError(reference string, err error) error { + if status.Code(err) == codes.NotFound { + return fmt.Errorf("%w: %s: %w", ErrUnresolvable, reference, err) + } + + return fmt.Errorf("%s: %w", reference, err) +} + +func validateComponent( + resolved *component.Component, + reference string, +) (*component.Component, error) { + if resolved == nil || resolved.Info.ID == uuid.Nil { + return nil, unresolvableError("%s has no canonical id", reference) + } + + if resolved.Type == devicetypes.ComponentTypeUnknown { + return nil, unresolvableError("%s has unknown type", reference) + } + + return resolved, nil +} + +func unresolvableError(format string, args ...any) error { + return fmt.Errorf("%w: %s", ErrUnresolvable, fmt.Sprintf(format, args...)) +} diff --git a/rest-api/flow/internal/inventory/resolver/resolver_test.go b/rest-api/flow/internal/inventory/resolver/resolver_test.go new file mode 100644 index 0000000000..ea24dab431 --- /dev/null +++ b/rest-api/flow/internal/inventory/resolver/resolver_test.go @@ -0,0 +1,341 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package resolver + +import ( + "context" + "errors" + "testing" + + identifier "github.com/NVIDIA/infra-controller/rest-api/flow/pkg/common/Identifier" + "github.com/NVIDIA/infra-controller/rest-api/flow/pkg/common/deviceinfo" + "github.com/NVIDIA/infra-controller/rest-api/flow/pkg/common/devicetypes" + "github.com/NVIDIA/infra-controller/rest-api/flow/pkg/common/location" + "github.com/NVIDIA/infra-controller/rest-api/flow/pkg/inventoryobjects/component" + "github.com/NVIDIA/infra-controller/rest-api/flow/pkg/inventoryobjects/rack" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestComponentByID(t *testing.T) { + componentID := uuid.New() + rackID := uuid.New() + inventoryErr := errors.New("inventory unavailable") + notFoundErr := status.Error(codes.NotFound, "component not found") + tests := map[string]struct { + id uuid.UUID + inventory *fakeInventory + wantID uuid.UUID + wantErr error + wantMessage string + retryableError bool + }{ + "found": { + id: componentID, + inventory: &fakeInventory{component: testComponent( + componentID, + rackID, + devicetypes.ComponentTypeCompute, + )}, + wantID: componentID, + }, + "missing ID": { + inventory: &fakeInventory{}, + wantErr: ErrUnresolvable, + wantMessage: "component id is required", + }, + "inventory failure": { + id: componentID, + inventory: &fakeInventory{err: inventoryErr}, + wantErr: inventoryErr, + wantMessage: "component id ", + retryableError: true, + }, + "store not found": { + id: componentID, + inventory: &fakeInventory{err: notFoundErr}, + wantErr: ErrUnresolvable, + wantMessage: "component id ", + }, + "unknown component type": { + id: componentID, + inventory: &fakeInventory{component: testComponent( + componentID, + rackID, + devicetypes.ComponentTypeUnknown, + )}, + wantErr: ErrUnresolvable, + wantMessage: "has unknown type", + }, + "mismatched canonical ID": { + id: componentID, + inventory: &fakeInventory{component: testComponent( + uuid.New(), + rackID, + devicetypes.ComponentTypeCompute, + )}, + wantErr: ErrUnresolvable, + wantMessage: "resolved to component id", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + resolved, err := New(test.inventory).ComponentByID( + context.Background(), + test.id, + ) + if test.wantErr != nil { + require.ErrorIs(t, err, test.wantErr) + require.ErrorContains(t, err, test.wantMessage) + if test.retryableError { + require.NotErrorIs(t, err, ErrUnresolvable) + } + return + } + + require.NoError(t, err) + require.Equal(t, test.wantID, resolved.Info.ID) + require.Equal(t, test.id, test.inventory.componentID) + }) + } +} + +func TestComponentByExternalID(t *testing.T) { + componentID := uuid.New() + rackID := uuid.New() + compute := testComponent(componentID, rackID, devicetypes.ComponentTypeCompute) + nvswitch := testComponent(uuid.New(), rackID, devicetypes.ComponentTypeNVSwitch) + inventoryErr := errors.New("inventory unavailable") + + tests := map[string]struct { + externalID string + componentType devicetypes.ComponentType + inventory *fakeInventory + expectedID uuid.UUID + wantErr error + wantMessage string + }{ + "typed": { + externalID: "component-1", + componentType: devicetypes.ComponentTypeCompute, + inventory: &fakeInventory{components: []*component.Component{nvswitch, compute}}, + expectedID: componentID, + }, + "untyped unambiguous": { + externalID: "component-1", + inventory: &fakeInventory{components: []*component.Component{compute}}, + expectedID: componentID, + }, + "untyped ambiguous": { + externalID: "component-1", + inventory: &fakeInventory{components: []*component.Component{compute, nvswitch}}, + wantErr: ErrUnresolvable, + }, + "missing external id": { + inventory: &fakeInventory{}, + wantErr: ErrUnresolvable, + }, + "inventory failure": { + externalID: "component-1", + inventory: &fakeInventory{err: inventoryErr}, + wantErr: inventoryErr, + wantMessage: `component external id "component-1"`, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + resolved, err := New(test.inventory).ComponentByExternalID( + context.Background(), + test.externalID, + test.componentType, + ) + if test.wantErr != nil { + require.ErrorIs(t, err, test.wantErr) + if test.wantMessage != "" { + require.ErrorContains(t, err, test.wantMessage) + require.NotErrorIs(t, err, ErrUnresolvable) + } + return + } + require.NoError(t, err) + require.Equal(t, test.expectedID, resolved.Info.ID) + require.Equal(t, []string{test.externalID}, test.inventory.externalIDs) + }) + } +} + +func TestRackByID(t *testing.T) { + rackID := uuid.New() + inventoryErr := errors.New("inventory unavailable") + tests := map[string]struct { + id uuid.UUID + withComponents bool + inventory *fakeInventory + wantErr error + wantMessage string + }{ + "found": { + id: rackID, + withComponents: true, + inventory: &fakeInventory{ + rack: rack.New(deviceinfo.DeviceInfo{ID: rackID}, location.Location{}), + }, + }, + "missing ID": { + inventory: &fakeInventory{}, + wantErr: ErrUnresolvable, + }, + "inventory failure": { + id: rackID, + inventory: &fakeInventory{err: inventoryErr}, + wantErr: inventoryErr, + wantMessage: "rack id ", + }, + "mismatched canonical ID": { + id: rackID, + inventory: &fakeInventory{ + rack: rack.New(deviceinfo.DeviceInfo{ID: uuid.New()}, location.Location{}), + }, + wantErr: ErrUnresolvable, + wantMessage: "resolved to rack id", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + resolved, err := New(test.inventory).RackByID( + context.Background(), + test.id, + test.withComponents, + ) + if test.wantErr != nil { + require.ErrorIs(t, err, test.wantErr) + if test.wantMessage != "" { + require.ErrorContains(t, err, test.wantMessage) + } + return + } + + require.NoError(t, err) + require.Equal(t, rackID, resolved.Info.ID) + require.Equal(t, identifier.Identifier{ID: rackID}, test.inventory.rackIdentifier) + require.Equal(t, test.withComponents, test.inventory.withComponents) + }) + } +} + +func TestRackByName(t *testing.T) { + rackID := uuid.New() + inventoryErr := errors.New("inventory unavailable") + notFoundErr := status.Error(codes.NotFound, "rack not found") + tests := map[string]struct { + name string + withComponents bool + inventory *fakeInventory + wantErr error + wantMessage string + }{ + "found": { + name: "rack-1", + inventory: &fakeInventory{ + rack: rack.New(deviceinfo.DeviceInfo{ID: rackID}, location.Location{}), + }, + }, + "missing name": { + inventory: &fakeInventory{}, + wantErr: ErrUnresolvable, + }, + "inventory failure": { + name: "rack-1", + inventory: &fakeInventory{err: inventoryErr}, + wantErr: inventoryErr, + wantMessage: `rack name "rack-1"`, + }, + "store not found": { + name: "rack-1", + inventory: &fakeInventory{err: notFoundErr}, + wantErr: ErrUnresolvable, + wantMessage: `rack name "rack-1"`, + }, + "nil rack": { + name: "rack-1", + inventory: &fakeInventory{}, + wantErr: ErrUnresolvable, + wantMessage: "has no canonical id", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + resolved, err := New(test.inventory).RackByName( + context.Background(), + test.name, + test.withComponents, + ) + if test.wantErr != nil { + require.ErrorIs(t, err, test.wantErr) + if test.wantMessage != "" { + require.ErrorContains(t, err, test.wantMessage) + } + return + } + + require.NoError(t, err) + require.Equal(t, rackID, resolved.Info.ID) + require.Equal(t, identifier.Identifier{Name: "rack-1"}, test.inventory.rackIdentifier) + require.Equal(t, test.withComponents, test.inventory.withComponents) + }) + } +} + +func testComponent( + id uuid.UUID, + rackID uuid.UUID, + componentType devicetypes.ComponentType, +) *component.Component { + resolved := component.New(componentType, &deviceinfo.DeviceInfo{ID: id}, "", nil) + resolved.RackID = rackID + return &resolved +} + +type fakeInventory struct { + component *component.Component + components []*component.Component + rack *rack.Rack + componentID uuid.UUID + externalIDs []string + rackIdentifier identifier.Identifier + withComponents bool + err error +} + +func (f *fakeInventory) GetComponentByID( + _ context.Context, + id uuid.UUID, +) (*component.Component, error) { + f.componentID = id + return f.component, f.err +} + +func (f *fakeInventory) GetComponentsByExternalIDs( + _ context.Context, + externalIDs []string, +) ([]*component.Component, error) { + f.externalIDs = externalIDs + return f.components, f.err +} + +func (f *fakeInventory) GetRackByIdentifier( + _ context.Context, + ref identifier.Identifier, + withComponents bool, +) (*rack.Rack, error) { + f.rackIdentifier = ref + f.withComponents = withComponents + return f.rack, f.err +}