Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions rest-api/flow/internal/converter/dao/event_rule.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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
Expand Down
40 changes: 40 additions & 0 deletions rest-api/flow/internal/converter/dao/event_rule_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
10 changes: 7 additions & 3 deletions rest-api/flow/internal/eventrule/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
}
Expand Down
54 changes: 39 additions & 15 deletions rest-api/flow/internal/eventrule/action_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
Expand Down Expand Up @@ -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,
))
})
}
}
7 changes: 4 additions & 3 deletions rest-api/flow/internal/eventrule/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 17 additions & 10 deletions rest-api/flow/internal/eventrule/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
10 changes: 8 additions & 2 deletions rest-api/flow/internal/eventrule/manager/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 3 additions & 2 deletions rest-api/flow/internal/eventrule/manager/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 0 additions & 8 deletions rest-api/flow/internal/eventrule/policycodec/action_v1.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions rest-api/flow/internal/eventrule/policycodec/codec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading