429e8bd398
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>
218 lines
6.6 KiB
Go
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)
|
|
}
|
|
}
|