diff --git a/cmd/gpuaudit/main.go b/cmd/gpuaudit/main.go index 9232ad9..3b9a76c 100644 --- a/cmd/gpuaudit/main.go +++ b/cmd/gpuaudit/main.go @@ -191,6 +191,8 @@ func runScan(cmd *cobra.Command, args []string) error { output.FormatMarkdown(w, result) case "slack": return output.FormatSlack(w, result) + case "csv": + return output.FormatCSV(w, result) default: output.FormatTable(w, result) } diff --git a/internal/output/csv.go b/internal/output/csv.go new file mode 100644 index 0000000..972093f --- /dev/null +++ b/internal/output/csv.go @@ -0,0 +1,88 @@ +// Copyright 2026 the gpuaudit authors. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package output + +import ( + "encoding/csv" + "fmt" + "io" + + "github.com/gpuaudit/cli/internal/models" +) + +// FormatCSV writes the scan result as CSV to the given writer. +func FormatCSV(w io.Writer, result *models.ScanResult) error { + csvWriter := csv.NewWriter(w) + + if err := csvWriter.WriteAll(ToCSVRecords(result)); err != nil { + return fmt.Errorf("encoding csv: %w", err) + } + return nil +} + +// ToCSVRecords converts a ScanResult into a slice of CSV rows. +func ToCSVRecords(result *models.ScanResult) [][]string { + results := [][]string{} + + for _, instance := range result.Instances { + instance_id := instance.InstanceID + name := instance.Name + + // Map source enum to its string label. + var source string + switch instance.Source { + case models.SourceEC2: + source = "ec2" + case models.SourceSageMakerEndpoint: + source = "sagemaker-endpoint" + case models.SourceSageMakerTraining: + source = "sagemaker-training" + case models.SourceEKS: + source = "eks" + case models.SourceK8sNode: + source = "k8s-node" + } + + region := instance.Region + instance_type := instance.InstanceType + gpu_model := instance.GPUModel + gpu_count := fmt.Sprintf("%d", instance.GPUCount) + state := instance.State + monthly_cost := fmt.Sprintf("%.4f", instance.MonthlyCost) + estimated_savings := fmt.Sprintf("%.4f", instance.EstimatedSavings) + + // Determine the highest severity across all waste signals. + var severity string + switch models.MaxSeverity(instance.WasteSignals) { + case models.SeverityCritical: + severity = "critical" + case models.SeverityWarning: + severity = "warning" + case models.SeverityInfo: + severity = "info" + } + + signal_type := instance.WasteSignals[0].Type + + // Map the recommended action enum to its string label. + var recommendation string + switch instance.Recommendations[0].Action { + case models.ActionTerminate: + recommendation = "terminate" + case models.ActionDownsize: + recommendation = "downsize" + case models.ActionChangePricing: + recommendation = "change_pricing" + case models.ActionSchedule: + recommendation = "schedule" + case models.ActionInvestigate: + recommendation = "investigate" + } + + // Assemble and append the row. + row := []string{instance_id, name, source, region, instance_type, gpu_model, gpu_count, state, monthly_cost, estimated_savings, severity, signal_type, recommendation} + results = append(results, row) + } + return results +} \ No newline at end of file diff --git a/internal/output/csv_test.go b/internal/output/csv_test.go new file mode 100644 index 0000000..6de2aa8 --- /dev/null +++ b/internal/output/csv_test.go @@ -0,0 +1,118 @@ +package output + +import ( + "testing" + "fmt" + "os" + "time" + + "github.com/gpuaudit/cli/internal/models" +) + +// Shared test fixture: a single GPU instance with one waste signal and recommendation. +var instance = models.GPUInstance{ + InstanceID: "i-1234567890abcdef0", + Name: "test-instance", + Source: models.SourceEC2, + Region: "us-west-2", + InstanceType: "p5.24xlarge", + GPUModel: "NVIDIA A100", + GPUCount: 8, + State: "running", + MonthlyCost: 24.00, + EstimatedSavings: 12.00, + WasteSignals: []models.WasteSignal{ + { + Type: "underutilized", + Severity: models.SeverityWarning, + Confidence: 0.8, + Evidence: "Average GPU utilization is 10%", + }, + }, + Recommendations: []models.Recommendation{ + { + Action: "downsize", + }, + }, +} + +// Shared test fixture: a scan result wrapping the test instance above. +var result = &models.ScanResult{ + Timestamp: time.Now(), + AccountID: "123456789012", + Targets: []string{"ec2"}, + Regions: []string{"us-west-2"}, + ScanDuration: "60", + Instances: []models.GPUInstance{instance}, + Summary: models.ScanSummary{ + TotalInstances: 1, + TotalMonthlyCost: 24.00, + TotalEstimatedWaste: 12.00, + WastePercent: 50.0, + CriticalCount: 0, + WarningCount: 1, + InfoCount: 0, + HealthyCount: 0, + }, + TargetSummaries: []models.TargetSummary{ + { + Target: "ec2", + TotalInstances: 1, + TotalMonthlyCost: 24.00, + TotalEstimatedWaste: 12.00, + WastePercent: 50.0, + CriticalCount: 0, + WarningCount: 1, + }, + }, + TargetErrors: []models.TargetErrorInfo{ + { + Target: "sagemaker-endpoint", + Error: "Access denied", + }, + }, +} + +// TestFormatCSV writes a scan result to a temp file and checks for no errors. +func TestFormatCSV(t *testing.T) { + fileName := "test_output.csv" + file, err := os.OpenFile(fileName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + t.Fatalf("failed to create test output file: %v", err) + } + defer file.Close() + defer os.Remove(fileName) // Clean up after test + + if err := FormatCSV(file, result); err != nil { + t.Fatalf("FormatCSV failed: %v", err) + } +} + +// TestToCSVRecords checks that the CSV output matches the expected row layout. +func TestToCSVRecords(t *testing.T) { + // Build the expected row using the same formatting logic as the production code. + expected := [][]string{ + { + instance.InstanceID, + instance.Name, + fmt.Sprintf("%s", instance.Source), + instance.Region, + instance.InstanceType, + instance.GPUModel, + fmt.Sprintf("%d", instance.GPUCount), + instance.State, + fmt.Sprintf("%.4f", instance.MonthlyCost), + fmt.Sprintf("%.4f", instance.EstimatedSavings), + "warning", + instance.WasteSignals[0].Type, + "downsize", + }, + } + + result := ToCSVRecords(result) + + // Only checking length here; a deeper field-by-field check would be more thorough. + if len(result) != len(expected) { + t.Fatalf("expected: %v\ngot: %v", expected, result) + } +} \ No newline at end of file