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
4 changes: 4 additions & 0 deletions rest-api/flow/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,7 @@ build
flow
.local_envrc
ignore

# Go event-rule target package; override the repository-wide Rust target ignore.
!internal/eventrule/target/
!internal/eventrule/target/*.go
30 changes: 17 additions & 13 deletions rest-api/flow/internal/converter/dao/event_action_execution.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ func EventActionExecutionTo(
Observations: execution.Observations,
Attempts: execution.Attempts,
StatusMessage: execution.StatusMessage,
FirstClaimedAt: execution.FirstClaimedAt,
CreatedAt: execution.CreatedAt,
UpdatedAt: execution.UpdatedAt,
NextAttemptAt: nextAttemptAt,
}, nil
Expand All @@ -55,20 +55,24 @@ func EventActionExecutionFrom(
}
execution := &eventrule.Execution{
ExecutionState: eventrule.ExecutionState{
Status: eventrule.ExecutionStatus(persisted.Status),
Reason: eventrule.ExecutionReason(persisted.Reason),
StatusMessage: persisted.StatusMessage,
ExecutionStatusDetails: eventrule.ExecutionStatusDetails{
Status: eventrule.ExecutionStatus(persisted.Status),
Reason: eventrule.ExecutionReason(persisted.Reason),
StatusMessage: persisted.StatusMessage,
},
NextAttemptAt: nextAttemptAt,
},
ID: persisted.ID,
EventID: persisted.EventID,
RuleID: persisted.RuleID,
ActionID: persisted.ActionID,
CorrelationKey: persisted.CorrelationKey,
Observations: persisted.Observations,
Attempts: persisted.Attempts,
FirstClaimedAt: persisted.FirstClaimedAt,
UpdatedAt: persisted.UpdatedAt,
ExecutionIdentity: eventrule.ExecutionIdentity{
EventID: persisted.EventID,
RuleID: persisted.RuleID,
ActionID: persisted.ActionID,
CorrelationKey: persisted.CorrelationKey,
},
ID: persisted.ID,
Observations: persisted.Observations,
Attempts: persisted.Attempts,
CreatedAt: persisted.CreatedAt,
UpdatedAt: persisted.UpdatedAt,
}
if err := execution.Validate(); err != nil {
return nil, fmt.Errorf("%w: %w", eventrule.ErrInvalidPersistedExecution, err)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,21 @@ import (
func TestEventActionExecutionRoundTrip(t *testing.T) {
now := time.Date(2026, 8, 5, 12, 0, 0, 0, time.UTC)
base := eventrule.Execution{
ExecutionState: eventrule.ExecutionState{Status: eventrule.ExecutionStatusClaimed},
ID: uuid.New(), EventID: uuid.New(), RuleID: uuid.New(), ActionID: "notify",
CorrelationKey: "incident-1", Observations: 2, Attempts: 1,
FirstClaimedAt: now, UpdatedAt: now.Add(time.Second),
ExecutionState: eventrule.ExecutionState{ExecutionStatusDetails: eventrule.ExecutionStatusDetails{Status: eventrule.ExecutionStatusPending}},
ExecutionIdentity: eventrule.ExecutionIdentity{
EventID: uuid.New(),
RuleID: uuid.New(),
ActionID: "notify",
CorrelationKey: "incident-1",
},
ID: uuid.New(),
Observations: 2,
Attempts: 1,
CreatedAt: now,
UpdatedAt: now.Add(time.Second),
}
tests := map[string]eventrule.Execution{
"claimed": executionWithStatus(base, eventrule.ExecutionStatusClaimed),
"pending": executionWithStatus(base, eventrule.ExecutionStatusPending),
"submitted": executionWithStatus(base, eventrule.ExecutionStatusSubmitted),
"completed": executionWithStatus(base, eventrule.ExecutionStatusCompleted),
"skipped": func() eventrule.Execution {
Expand Down Expand Up @@ -60,7 +68,7 @@ func TestEventActionExecutionRoundTrip(t *testing.T) {
func TestEventActionExecutionToRejectsInvalidDomain(t *testing.T) {
tests := map[string]*eventrule.Execution{
"nil": nil,
"invalid id": {ExecutionState: eventrule.ExecutionState{Status: eventrule.ExecutionStatusClaimed}},
"invalid id": {ExecutionState: eventrule.ExecutionState{ExecutionStatusDetails: eventrule.ExecutionStatusDetails{Status: eventrule.ExecutionStatusPending}}},
}
for name, execution := range tests {
t.Run(name, func(t *testing.T) {
Expand All @@ -74,10 +82,15 @@ func TestEventActionExecutionToRejectsInvalidDomain(t *testing.T) {
func TestEventActionExecutionFrom(t *testing.T) {
now := time.Date(2026, 8, 5, 12, 0, 0, 0, time.UTC)
valid, err := EventActionExecutionTo(&eventrule.Execution{
ExecutionState: eventrule.ExecutionState{Status: eventrule.ExecutionStatusClaimed},
ID: uuid.New(), EventID: uuid.New(), RuleID: uuid.New(), ActionID: "notify",
ExecutionState: eventrule.ExecutionState{ExecutionStatusDetails: eventrule.ExecutionStatusDetails{Status: eventrule.ExecutionStatusPending}},
ExecutionIdentity: eventrule.ExecutionIdentity{
EventID: uuid.New(),
RuleID: uuid.New(),
ActionID: "notify",
},
ID: uuid.New(),
Observations: 1, Attempts: 1,
FirstClaimedAt: now, UpdatedAt: now,
CreatedAt: now, UpdatedAt: now,
})
require.NoError(t, err)

Expand Down
5 changes: 3 additions & 2 deletions rest-api/flow/internal/db/model/event_action_execution.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import (
"github.com/uptrace/bun"
)

// EventActionExecution is the bun model for the event_action_executions table.
// EventActionExecution is the prospective persistence model for an event-rule
// action execution. The database table is introduced in a later phase.
type EventActionExecution struct {
bun.BaseModel `bun:"table:event_action_executions,alias:eae"`

Expand All @@ -24,7 +25,7 @@ type EventActionExecution struct {
Observations int `bun:"observations,notnull"`
Attempts int `bun:"attempts,notnull"`
StatusMessage string `bun:"status_message,notnull"`
FirstClaimedAt time.Time `bun:"first_claimed_at,notnull"`
CreatedAt time.Time `bun:"created_at,notnull"`
UpdatedAt time.Time `bun:"updated_at,notnull"`
NextAttemptAt *time.Time `bun:"next_attempt_at"`
}
47 changes: 40 additions & 7 deletions rest-api/flow/internal/eventrule/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,13 @@ func (c ActionCondition) AppliesTo(envelope Envelope, resource ResolvedResource)
return false
}

if c.ComponentTypes != nil &&
!slices.Contains(c.ComponentTypes, resource.ComponentType) {
return false
if c.ComponentTypes != nil {
if resource.Kind != ResourceKindComponent {
return false
}
if !slices.Contains(c.ComponentTypes, resource.ComponentType) {
return false
}
}

return true
Expand Down Expand Up @@ -98,9 +102,10 @@ func (s ConflictStrategy) validate() error {

// ActionSpec is the closed set of typed responses supported by an action.
// The unexported validation method prevents implementations outside this
// package while allowing processors to identify a specification with Type.
// package while exposing its type and target-resolution behavior.
type ActionSpec interface {
Type() ActionType
TargetResolutionStrategy() TargetStrategy
validate() error
}

Expand Down Expand Up @@ -175,26 +180,36 @@ func CloneActions(actions []Action) []Action {
return cloned
}

// TargetStrategy identifies how a target-bearing action resolves concrete
// TargetStrategy identifies whether and how an action resolves concrete
// operation targets.
type TargetStrategy string

const (
TargetStrategyNone TargetStrategy = "none"
TargetStrategyComponent TargetStrategy = "component"
TargetStrategyRack TargetStrategy = "rack"
TargetStrategyAffectedComponents TargetStrategy = "affected_components"
)

// Validate checks that the target strategy is supported by the schema.
// Validate checks that the target strategy is supported by the domain.
func (s TargetStrategy) Validate() error {
switch s {
case TargetStrategyComponent, TargetStrategyRack, TargetStrategyAffectedComponents:
case TargetStrategyNone,
TargetStrategyComponent,
TargetStrategyRack,
TargetStrategyAffectedComponents:
return nil
default:
return fmt.Errorf("unknown target strategy %q", s)
}
}

// RequiresResolution reports whether concrete targets must be resolved for
// the strategy.
func (s TargetStrategy) RequiresResolution() bool {
return s != TargetStrategyNone
}

// SubmitTask describes a task submission requested by an event rule.
type SubmitTask struct {
OperationType taskcommon.TaskType
Expand All @@ -209,6 +224,11 @@ func (s SubmitTask) Type() ActionType {
return ActionTypeSubmitTask
}

// TargetResolutionStrategy returns the task's target strategy.
func (s SubmitTask) TargetResolutionStrategy() TargetStrategy {
return s.TargetStrategy
}

func (s SubmitTask) validate() error {
if !s.OperationType.IsValid() {
return fmt.Errorf("operation_type %q is invalid", s.OperationType)
Expand All @@ -221,6 +241,9 @@ func (s SubmitTask) validate() error {
if err := s.TargetStrategy.Validate(); err != nil {
return err
}
if !s.TargetStrategy.RequiresResolution() {
return fmt.Errorf("submit task target strategy must require resolution")
}

if err := s.ConflictStrategy.validate(); err != nil {
return err
Expand All @@ -240,6 +263,11 @@ func (s SendAlert) Type() ActionType {
return ActionTypeSendAlert
}

// TargetResolutionStrategy reports that alerts do not resolve targets.
func (SendAlert) TargetResolutionStrategy() TargetStrategy {
return TargetStrategyNone
}

func (s SendAlert) validate() error {
if err := s.Severity.Validate(); err != nil {
return err
Expand All @@ -261,6 +289,11 @@ func (Noop) Type() ActionType {
return ActionTypeNoop
}

// TargetResolutionStrategy reports that no-op actions do not resolve targets.
func (Noop) TargetResolutionStrategy() TargetStrategy {
return TargetStrategyNone
}

func (n Noop) validate() error {
return validateOptionalString("noop reason", n.Reason)
}
60 changes: 56 additions & 4 deletions rest-api/flow/internal/eventrule/action_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package eventrule

import (
"slices"
"testing"
"time"

Expand Down Expand Up @@ -50,9 +51,19 @@ func TestActionsValidate(t *testing.T) {
}),
NewAction("noop", ActionCondition{}, Noop{Reason: "record only"}),
)
wantStrategies := append(
slices.Clone(strategies),
TargetStrategyNone,
TargetStrategyNone,
)

for i := range actions {
require.NoError(t, actions[i].Validate())
require.Equal(
t,
wantStrategies[i],
actions[i].Spec.TargetResolutionStrategy(),
)
}
}

Expand All @@ -65,6 +76,8 @@ func TestActionRejectsInvalidDomainValues(t *testing.T) {
}
unknownStrategySpec := validTaskSpec
unknownStrategySpec.TargetStrategy = "unknown"
noneStrategySpec := validTaskSpec
noneStrategySpec.TargetStrategy = TargetStrategyNone
mismatchedOperationSpec := validTaskSpec
mismatchedOperationSpec.OperationCode = taskcommon.OpCodeFirmwareControlUpgrade
tests := map[string]Action{
Expand All @@ -80,6 +93,9 @@ func TestActionRejectsInvalidDomainValues(t *testing.T) {
"unknown strategy": NewAction(
"task", ActionCondition{}, unknownStrategySpec,
),
"task without target resolution": NewAction(
"task", ActionCondition{}, noneStrategySpec,
),
"mismatched operation": NewAction(
"task", ActionCondition{}, mismatchedOperationSpec,
),
Expand All @@ -93,6 +109,25 @@ func TestActionRejectsInvalidDomainValues(t *testing.T) {
}
}

func TestTargetStrategy_RequiresResolution(t *testing.T) {
tests := map[string]struct {
strategy TargetStrategy
want bool
}{
"none": {strategy: TargetStrategyNone},
"component": {strategy: TargetStrategyComponent, want: true},
"rack": {strategy: TargetStrategyRack, want: true},
"affected components": {strategy: TargetStrategyAffectedComponents, want: true},
}

for name, test := range tests {
t.Run(name, func(t *testing.T) {
require.NoError(t, test.strategy.Validate())
require.Equal(t, test.want, test.strategy.RequiresResolution())
})
}
}

func TestRuleValidatesPolicy(t *testing.T) {
action := NewAction("noop", ActionCondition{}, Noop{})
rule := Rule{
Expand Down Expand Up @@ -132,18 +167,35 @@ func TestActionConditionAppliesTo(t *testing.T) {
"matches severity and component type": {
condition: condition,
envelope: Envelope{Severity: SeverityCritical},
resource: ResolvedResource{ComponentType: flowtypes.ComponentTypeCompute},
want: true,
resource: ResolvedResource{
Kind: ResourceKindComponent,
ComponentType: flowtypes.ComponentTypeCompute,
},
want: true,
},
"rejects severity": {
condition: condition,
envelope: Envelope{Severity: SeverityInfo},
resource: ResolvedResource{ComponentType: flowtypes.ComponentTypeCompute},
resource: ResolvedResource{
Kind: ResourceKindComponent,
ComponentType: flowtypes.ComponentTypeCompute,
},
},
"rejects component type": {
condition: condition,
envelope: Envelope{Severity: SeverityCritical},
resource: ResolvedResource{ComponentType: flowtypes.ComponentTypeNVSwitch},
resource: ResolvedResource{
Kind: ResourceKindComponent,
ComponentType: flowtypes.ComponentTypeNVSwitch,
},
},
"component type condition rejects rack": {
condition: condition,
envelope: Envelope{Severity: SeverityCritical},
resource: ResolvedResource{
Kind: ResourceKindRack,
ComponentType: flowtypes.ComponentTypeCompute,
},
},
"empty severity set matches nothing": {
condition: ActionCondition{Severities: []Severity{}},
Expand Down
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 @@ -77,9 +77,10 @@
// condition applies to every event.
//
// Task actions use a named TargetStrategy rather than an arbitrary inventory
// query. Target resolution and side effects occur outside this package. If a
// target strategy resolves no resources, the processor should record the
// action as skipped and must not submit a task.
// query; actions without targets use TargetStrategyNone. Target resolution and
// side effects occur outside this package. If a target strategy resolves no
// resources, the processor should record the action as skipped and must not
// submit a task.
//
// # Validation boundaries
//
Expand Down
24 changes: 24 additions & 0 deletions rest-api/flow/internal/eventrule/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,3 +156,27 @@ type ResolvedResource struct {
RackID uuid.UUID
ComponentType flowtypes.ComponentType
}

// Validate checks the canonical identity and attributes established during
// enrichment.
func (r ResolvedResource) Validate() error {
if err := r.Kind.Validate(); err != nil {
return err
}
if r.ID == uuid.Nil {
return fmt.Errorf("resolved resource id is required")
}
if r.RackID == uuid.Nil {
return fmt.Errorf("resolved resource rack id is required")
}
if r.Kind == ResourceKindComponent {
if err := r.ComponentType.Validate(); err != nil {
return fmt.Errorf("resolved resource component type: %w", err)
}
} else {
if r.ID != r.RackID {
return fmt.Errorf("resolved rack resource id must equal rack id")
}
}
return nil
}
Comment thread
jw-nvidia marked this conversation as resolved.
Loading
Loading