// 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:", "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) } }