Files
Alphaeus Mote 429e8bd398 fix(rules): record last run outcome on the rule row
The runner updated the rule_runs history but never wrote the rules table's
last_run_utc / last_run_result columns, so the rules list showed blank Last
Run / Last Result. Add RuleRepository.UpdateLastRun and call it from the
runner's finalize and fail paths with the run's completion time and status.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:48:27 -04:00

218 lines
6.6 KiB
Go

// Package runner coordinates end-to-end rule execution.
package runner
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
"github.com/Grace-Solutions/OrchestrAD/internal/models"
"github.com/Grace-Solutions/OrchestrAD/internal/repository"
"github.com/Grace-Solutions/OrchestrAD/internal/rules/engine"
"github.com/Grace-Solutions/OrchestrAD/internal/services"
"github.com/Grace-Solutions/OrchestrAD/internal/types"
)
// Runner loads rules and their dependencies, executes them via the engine,
// and records the resulting run and action histories.
type Runner struct {
ruleRepo *repository.RuleRepository
connRepo *repository.ConnectionRepository
runRepo *repository.RuleRunRepository
connService *services.ConnectionService
engine *engine.Engine
logger *logging.Logger
}
// New creates a new Runner
func New(db *sql.DB, connService *services.ConnectionService, logger *logging.Logger) *Runner {
eng := engine.NewEngine(logger)
eng.SetManagedStore(repository.NewManagedMemberRepository(db))
return &Runner{
ruleRepo: repository.NewRuleRepository(db),
connRepo: repository.NewConnectionRepository(db),
runRepo: repository.NewRuleRunRepository(db),
connService: connService,
engine: eng,
logger: logger,
}
}
// RunRule executes the rule identified by ruleID. triggeredBy is a free-form
// label stored on the run record (e.g. "scheduler", "user:<id>", "api").
func (r *Runner) RunRule(ctx context.Context, ruleID string, triggeredBy string) error {
rule, err := r.ruleRepo.GetByID(ruleID)
if err != nil {
return fmt.Errorf("loading rule: %w", err)
}
if rule == nil {
return fmt.Errorf("rule not found: %s", ruleID)
}
if !rule.IsEnabled {
r.logger.Info("Runner", "Rule '%s' is disabled, skipping", rule.Name)
return nil
}
conn, err := r.connRepo.GetByID(rule.ADConnectionID)
if err != nil {
return fmt.Errorf("loading AD connection: %w", err)
}
if conn == nil {
return fmt.Errorf("AD connection not found: %s", rule.ADConnectionID)
}
run := &models.RuleRun{
RuleID: rule.ID,
Status: string(types.RunStatusRunning),
StartedUTC: time.Now().UTC(),
ExecutionMode: rule.ExecutionMode,
}
if triggeredBy != "" {
tb := triggeredBy
run.TriggeredBy = &tb
}
if err := r.runRepo.Create(run); err != nil {
return fmt.Errorf("creating rule run: %w", err)
}
client, err := r.connService.BuildClient(conn)
if err != nil {
r.failRun(run, fmt.Sprintf("LDAP connection failed: %v", err))
return fmt.Errorf("building LDAP client: %w", err)
}
defer client.Close()
result := r.engine.Execute(ctx, rule, conn, client)
for _, ar := range result.ActionResults {
r.persistActionRecord(run.ID, ar)
}
r.finalizeRun(run, result)
return nil
}
// PreviewRule loads the rule and its connection, builds an LDAP client, and
// returns a preview of matched objects and planned actions without making
// any changes to the directory.
func (r *Runner) PreviewRule(ctx context.Context, ruleID string) (*engine.PreviewResult, error) {
rule, err := r.ruleRepo.GetByID(ruleID)
if err != nil {
return nil, fmt.Errorf("loading rule: %w", err)
}
if rule == nil {
return nil, fmt.Errorf("rule not found: %s", ruleID)
}
conn, err := r.connRepo.GetByID(rule.ADConnectionID)
if err != nil {
return nil, fmt.Errorf("loading AD connection: %w", err)
}
if conn == nil {
return nil, fmt.Errorf("AD connection not found: %s", rule.ADConnectionID)
}
client, err := r.connService.BuildClient(conn)
if err != nil {
return nil, fmt.Errorf("building LDAP client: %w", err)
}
defer client.Close()
return r.engine.Preview(ctx, rule, conn, client)
}
// PreviewRuleSpec previews an unsaved rule draft: it resolves the connection by
// ID, builds a client, and returns matched objects and planned actions without
// persisting the rule. Used by the editor's live preview.
func (r *Runner) PreviewRuleSpec(ctx context.Context, rule *models.Rule) (*engine.PreviewResult, error) {
if rule.ADConnectionID == "" {
return nil, fmt.Errorf("adConnectionId is required")
}
conn, err := r.connRepo.GetByID(rule.ADConnectionID)
if err != nil {
return nil, fmt.Errorf("loading AD connection: %w", err)
}
if conn == nil {
return nil, fmt.Errorf("AD connection not found: %s", rule.ADConnectionID)
}
client, err := r.connService.BuildClient(conn)
if err != nil {
return nil, fmt.Errorf("building LDAP client: %w", err)
}
defer client.Close()
return r.engine.Preview(ctx, rule, conn, client)
}
func (r *Runner) persistActionRecord(runID string, ar engine.ActionResult) {
status := "Succeeded"
if !ar.Success {
status = "Failed"
}
rec := &models.RuleRunAction{
RuleRunID: runID,
RuleActionID: ar.ActionID,
ObjectDN: ar.ObjectDN,
ActionType: ar.ActionType,
Status: status,
}
if len(ar.Details) > 0 {
if b, err := json.Marshal(ar.Details); err == nil {
s := string(b)
rec.DetailsJSON = &s
}
}
if ar.Error != "" {
em := ar.Error
rec.ErrorMessage = &em
}
dur := int(ar.Duration.Milliseconds())
rec.DurationMS = &dur
if err := r.runRepo.CreateAction(rec); err != nil {
r.logger.Warn("Runner", "Failed to persist action record for run %s: %v", runID, err)
}
}
func (r *Runner) finalizeRun(run *models.RuleRun, result *engine.ExecutionResult) {
completed := result.CompletedAt
dur := int(result.Duration.Milliseconds())
run.Status = string(result.Status)
run.CompletedUTC = &completed
run.DurationMS = &dur
run.ObjectsMatched = result.ObjectsMatched
run.ObjectsProcessed = result.ObjectsProcessed
run.ActionsExecuted = result.ActionsExecuted
run.ActionsFailed = result.ActionsFailed
if len(result.Errors) > 0 {
msg := strings.Join(result.Errors, "; ")
run.ErrorMessage = &msg
}
if err := r.runRepo.Update(run); err != nil {
r.logger.Warn("Runner", "Failed to update rule run %s: %v", run.ID, err)
}
if err := r.ruleRepo.UpdateLastRun(run.RuleID, completed, run.Status); err != nil {
r.logger.Warn("Runner", "Failed to update last-run on rule %s: %v", run.RuleID, err)
}
}
func (r *Runner) failRun(run *models.RuleRun, msg string) {
now := time.Now().UTC()
dur := int(now.Sub(run.StartedUTC).Milliseconds())
run.Status = string(types.RunStatusFailed)
run.CompletedUTC = &now
run.DurationMS = &dur
run.ErrorMessage = &msg
if err := r.runRepo.Update(run); err != nil {
r.logger.Warn("Runner", "Failed to update failed rule run %s: %v", run.ID, err)
}
if err := r.ruleRepo.UpdateLastRun(run.RuleID, now, string(types.RunStatusFailed)); err != nil {
r.logger.Warn("Runner", "Failed to update last-run on rule %s: %v", run.RuleID, err)
}
}