diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 9fbfaf4..a708dfc 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -1,18 +1,120 @@ package cli import ( + "context" + "encoding/json" "fmt" + "os" + "github.com/kaustuvprajapati/devopsctl/internal/doctor" + "github.com/kaustuvprajapati/devopsctl/internal/reporter" "github.com/spf13/cobra" ) +// awsModule wraps AWS checks as a doctor.Module +type awsModule struct{} + +func (m *awsModule) Name() string { return "aws" } + +func (m *awsModule) Run(ctx context.Context) ([]reporter.CheckResult, error) { + // Will be implemented in CLI after AWS clients are available + return []reporter.CheckResult{}, nil +} + +// dockerModule wraps Docker checks as a doctor.Module +type dockerModule struct{} + +func (m *dockerModule) Name() string { return "docker" } + +func (m *dockerModule) Run(ctx context.Context) ([]reporter.CheckResult, error) { + // Will be implemented in CLI after Docker runner is available + return []reporter.CheckResult{}, nil +} + +// terraformModule wraps Terraform checks as a doctor.Module +type terraformModule struct{} + +func (m *terraformModule) Name() string { return "terraform" } + +func (m *terraformModule) Run(ctx context.Context) ([]reporter.CheckResult, error) { + // Will be implemented in CLI after Terraform runner is available + return []reporter.CheckResult{}, nil +} + +// gitModule wraps Git checks as a doctor.Module +type gitModule struct{} + +func (m *gitModule) Name() string { return "git" } + +func (m *gitModule) Run(ctx context.Context) ([]reporter.CheckResult, error) { + // Will be implemented in CLI after Git runner is available + return []reporter.CheckResult{}, nil +} + var doctorCmd = &cobra.Command{ Use: "doctor", Short: "Run all checks and generate health report", Long: `Run all available audit and validation checks, aggregate results, and generate a comprehensive health report.`, RunE: func(cmd *cobra.Command, args []string) error { - fmt.Println("Doctor not yet implemented") + engine := doctor.NewEngine() + + // Register all modules + if err := engine.Register(&awsModule{}); err != nil { + return fmt.Errorf("failed to register aws module: %w", err) + } + if err := engine.Register(&dockerModule{}); err != nil { + return fmt.Errorf("failed to register docker module: %w", err) + } + if err := engine.Register(&terraformModule{}); err != nil { + return fmt.Errorf("failed to register terraform module: %w", err) + } + if err := engine.Register(&gitModule{}); err != nil { + return fmt.Errorf("failed to register git module: %w", err) + } + + // Run all modules + reports, err := engine.RunAll(context.Background()) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: some modules encountered errors: %v\n", err) + } + + // Compute summary + summary := doctor.ComputeSummary(reports) + + // Output results + w, err := resolveWriter(cmd) + if err != nil { + return err + } + if w != os.Stdout { + defer w.Close() + } + + rep := resolveReporter() + + // Render each module's results + for _, r := range reports { + report := &reporter.Report{Module: r.Module, Results: r.Results} + if err := rep.Render(w, report); err != nil { + return err + } + if r.Error != "" { + fmt.Fprintf(w, " [ERROR] %s\n", r.Error) + } + } + + // Print summary for JSON output + if jsonOutput { + summaryJSON, _ := json.MarshalIndent(summary, "", " ") + fmt.Fprintf(w, "\n%s\n", summaryJSON) + } + + // Exit with appropriate code + if code := doctor.ExitCode(reports); code > 0 { + os.Exit(code) + } + return nil }, } diff --git a/internal/doctor/engine.go b/internal/doctor/engine.go new file mode 100644 index 0000000..aac0bb3 --- /dev/null +++ b/internal/doctor/engine.go @@ -0,0 +1,75 @@ +package doctor + +import ( + "context" + "fmt" + + "github.com/kaustuvprajapati/devopsctl/internal/reporter" +) + +// ModuleReport holds results from a single module execution. +type ModuleReport struct { + Module string `json:"module"` + Results []reporter.CheckResult `json:"results"` + Error string `json:"error,omitempty"` +} + +// Engine orchestrates all registered modules. +type Engine struct { + registry *Registry +} + +// NewEngine creates a new doctor engine. +func NewEngine() *Engine { + return &Engine{ + registry: NewRegistry(), + } +} + +// Register adds a module to the engine. +func (e *Engine) Register(m Module) error { + return e.registry.Register(m) +} + +// RunAll executes all registered modules and returns aggregated results. +func (e *Engine) RunAll(ctx context.Context) ([]ModuleReport, error) { + var reports []ModuleReport + moduleNames := e.registry.List() + + for _, name := range moduleNames { + module, ok := e.registry.Get(name) + if !ok { + continue + } + + report := ModuleReport{Module: name} + + results, err := module.Run(ctx) + if err != nil { + report.Error = err.Error() + // Continue running other modules despite failure + } + report.Results = results + reports = append(reports, report) + } + + // Check if any module failed + var errs []string + for _, r := range reports { + if r.Error != "" { + errs = append(errs, fmt.Sprintf("%s: %s", r.Module, r.Error)) + } + } + + var err error + if len(errs) > 0 { + err = fmt.Errorf("some modules failed: %v", errs) + } + + return reports, err +} + +// Registry returns the underlying registry. +func (e *Engine) Registry() *Registry { + return e.registry +} diff --git a/internal/doctor/engine_test.go b/internal/doctor/engine_test.go new file mode 100644 index 0000000..9f01984 --- /dev/null +++ b/internal/doctor/engine_test.go @@ -0,0 +1,141 @@ +package doctor + +import ( + "context" + "errors" + "testing" + + "github.com/kaustuvprajapati/devopsctl/internal/reporter" +) + +// mockModule implements Module for testing +type mockModule struct { + name string + results []reporter.CheckResult + err error +} + +func (m *mockModule) Name() string { return m.name } + +func (m *mockModule) Run(ctx context.Context) ([]reporter.CheckResult, error) { + return m.results, m.err +} + +// TestRegistryRegister tests module registration +func TestRegistryRegister(t *testing.T) { + registry := NewRegistry() + + module := &mockModule{name: "test", results: []reporter.CheckResult{}} + err := registry.Register(module) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if registry.Len() != 1 { + t.Errorf("Expected 1 module, got %d", registry.Len()) + } +} + +// TestRegistryDuplicateRegistration tests duplicate registration +func TestRegistryDuplicateRegistration(t *testing.T) { + registry := NewRegistry() + + module := &mockModule{name: "test", results: []reporter.CheckResult{}} + registry.Register(module) + + err := registry.Register(module) + if err != ErrModuleAlreadyRegistered { + t.Errorf("Expected ErrModuleAlreadyRegistered, got %v", err) + } +} + +// TestRegistryNilModule tests nil module registration +func TestRegistryNilModule(t *testing.T) { + registry := NewRegistry() + + err := registry.Register(nil) + if err != ErrNilModule { + t.Errorf("Expected ErrNilModule, got %v", err) + } +} + +// TestEngineRunAll tests running all modules +func TestEngineRunAll(t *testing.T) { + engine := NewEngine() + + // Register modules + engine.Register(&mockModule{ + name: "module1", + results: []reporter.CheckResult{ + {CheckName: "check1", Severity: "LOW", ResourceID: "res1"}, + }, + }) + engine.Register(&mockModule{ + name: "module2", + results: []reporter.CheckResult{ + {CheckName: "check2", Severity: "HIGH", ResourceID: "res2"}, + }, + }) + + reports, err := engine.RunAll(context.Background()) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if len(reports) != 2 { + t.Errorf("Expected 2 reports, got %d", len(reports)) + } +} + +// TestEnginePartialFailure tests partial module failure handling +func TestEnginePartialFailure(t *testing.T) { + engine := NewEngine() + + engine.Register(&mockModule{ + name: "success", + results: []reporter.CheckResult{ + {CheckName: "check1", Severity: "LOW"}, + }, + }) + engine.Register(&mockModule{ + name: "failure", + results: nil, + err: errors.New("module error"), + }) + + reports, err := engine.RunAll(context.Background()) + if err == nil { + t.Error("Expected error due to module failure") + } + + if len(reports) != 2 { + t.Errorf("Expected 2 reports, got %d", len(reports)) + } + + // Find the failed module + var failedReport ModuleReport + for _, r := range reports { + if r.Module == "failure" { + failedReport = r + break + } + } + + if failedReport.Error == "" { + t.Error("Expected error message in failed module report") + } +} + +// TestEngineNoModules tests running with no modules +func TestEngineNoModules(t *testing.T) { + engine := NewEngine() + + reports, err := engine.RunAll(context.Background()) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if len(reports) != 0 { + t.Errorf("Expected 0 reports, got %d", len(reports)) + } +} diff --git a/internal/doctor/registry.go b/internal/doctor/registry.go new file mode 100644 index 0000000..e2480ab --- /dev/null +++ b/internal/doctor/registry.go @@ -0,0 +1,79 @@ +package doctor + +import ( + "context" + + "github.com/kaustuvprajapati/devopsctl/internal/reporter" +) + +// Module represents an audit/validation module that can be run by the doctor engine. +type Module interface { + // Name returns the module identifier + Name() string + + // Run executes the module and returns check results + Run(ctx context.Context) ([]reporter.CheckResult, error) +} + +// Registry holds registered modules. +type Registry struct { + modules map[string]Module +} + +// NewRegistry creates a new module registry. +func NewRegistry() *Registry { + return &Registry{ + modules: make(map[string]Module), + } +} + +// Register adds a module to the registry. +func (r *Registry) Register(m Module) error { + if m == nil { + return ErrNilModule + } + name := m.Name() + if name == "" { + return ErrEmptyModuleName + } + if _, exists := r.modules[name]; exists { + return ErrModuleAlreadyRegistered + } + r.modules[name] = m + return nil +} + +// Get returns a module by name. +func (r *Registry) Get(name string) (Module, bool) { + m, ok := r.modules[name] + return m, ok +} + +// List returns all registered module names. +func (r *Registry) List() []string { + names := make([]string, 0, len(r.modules)) + for name := range r.modules { + names = append(names, name) + } + return names +} + +// Len returns the number of registered modules. +func (r *Registry) Len() int { + return len(r.modules) +} + +// Errors for registry operations +var ( + ErrNilModule = &RegistryError{"nil module not allowed"} + ErrEmptyModuleName = &RegistryError{"module name cannot be empty"} + ErrModuleAlreadyRegistered = &RegistryError{"module already registered"} +) + +type RegistryError struct { + msg string +} + +func (e *RegistryError) Error() string { + return e.msg +} diff --git a/internal/doctor/scoring.go b/internal/doctor/scoring.go new file mode 100644 index 0000000..f6e42d9 --- /dev/null +++ b/internal/doctor/scoring.go @@ -0,0 +1,79 @@ +package doctor + +import ( + "github.com/kaustuvprajapati/devopsctl/internal/severity" +) + +// Summary holds aggregated scoring information. +type Summary struct { + TotalFindings int `json:"total_findings"` + Critical int `json:"critical"` + High int `json:"high"` + Medium int `json:"medium"` + Low int `json:"low"` + Score int `json:"score"` + ModulesFailed int `json:"modules_failed"` + ModuleErrors map[string]string `json:"module_errors,omitempty"` +} + +// ComputeSummary calculates aggregate statistics from module reports. +func ComputeSummary(reports []ModuleReport) Summary { + summary := Summary{ + ModuleErrors: make(map[string]string), + } + + for _, report := range reports { + if report.Error != "" { + summary.ModulesFailed++ + summary.ModuleErrors[report.Module] = report.Error + continue + } + + for _, result := range report.Results { + summary.TotalFindings++ + + switch severity.Level(result.Severity) { + case severity.Critical: + summary.Critical++ + summary.Score += severity.Critical.Weight() + case severity.High: + summary.High++ + summary.Score += severity.High.Weight() + case severity.Medium: + summary.Medium++ + summary.Score += severity.Medium.Weight() + case severity.Low: + summary.Low++ + summary.Score += severity.Low.Weight() + } + } + } + + return summary +} + +// HighestSeverity returns the highest severity level from all results. +func HighestSeverity(reports []ModuleReport) severity.Level { + levels := make([]severity.Level, 0) + + for _, report := range reports { + for _, result := range report.Results { + levels = append(levels, severity.Level(result.Severity)) + } + } + + if len(levels) == 0 { + return "" + } + + return severity.Highest(levels) +} + +// ExitCode returns the exit code based on highest severity. +func ExitCode(reports []ModuleReport) int { + highest := HighestSeverity(reports) + if highest == "" { + return 0 + } + return highest.ExitCode() +} diff --git a/internal/reporter/markdown.go b/internal/reporter/markdown.go new file mode 100644 index 0000000..282b1ff --- /dev/null +++ b/internal/reporter/markdown.go @@ -0,0 +1,61 @@ +package reporter + +import ( + "fmt" + "io" + "text/tabwriter" +) + +// MarkdownReporter renders reports in Markdown format. +type MarkdownReporter struct{} + +// NewMarkdownReporter creates a new MarkdownReporter. +func NewMarkdownReporter() *MarkdownReporter { + return &MarkdownReporter{} +} + +// Render outputs the report in Markdown format. +func (r *MarkdownReporter) Render(w io.Writer, report *Report) error { + fmt.Fprintf(w, "# %s Audit Report\n\n", titleCase(report.Module)) + + if len(report.Results) == 0 { + fmt.Fprintf(w, "No findings.\n\n") + return nil + } + + // Create a tabwriter for alignment + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + fmt.Fprintf(tw, "| %s | %s | %s | %s |\n", "Severity", "Check", "Resource", "Message") + fmt.Fprintf(tw, "| --- | --- | --- | --- |\n") + + for _, result := range report.Results { + fmt.Fprintf(tw, "| %s | %s | %s | %s |\n", + result.Severity, + result.CheckName, + result.ResourceID, + result.Message, + ) + } + + tw.Flush() + fmt.Fprintf(w, "\n") + + // Add recommendations section + fmt.Fprintf(w, "## Recommendations\n\n") + for _, result := range report.Results { + if result.Recommendation != "" { + fmt.Fprintf(w, "- **%s**: %s\n", result.CheckName, result.Recommendation) + } + } + + fmt.Fprintf(w, "\n") + return nil +} + +// titleCase converts a string to title case. +func titleCase(s string) string { + if len(s) == 0 { + return s + } + return string(s[0]-'a'+'A') + s[1:] +}