Skip to content

Commit a7dc5ea

Browse files
committed
Add CSV output format with FormatCSV and ToCSVRecords
1 parent 1b4a207 commit a7dc5ea

3 files changed

Lines changed: 208 additions & 0 deletions

File tree

cmd/gpuaudit/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,8 @@ func runScan(cmd *cobra.Command, args []string) error {
191191
output.FormatMarkdown(w, result)
192192
case "slack":
193193
return output.FormatSlack(w, result)
194+
case "csv":
195+
return output.FormatCSV(w, result)
194196
default:
195197
output.FormatTable(w, result)
196198
}

internal/output/csv.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// Copyright 2026 the gpuaudit authors. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package output
5+
6+
import (
7+
"encoding/csv"
8+
"fmt"
9+
"io"
10+
11+
"github.com/gpuaudit/cli/internal/models"
12+
)
13+
14+
// FormatCSV writes the scan result as CSV to the given writer.
15+
func FormatCSV(w io.Writer, result *models.ScanResult) error {
16+
csvWriter := csv.NewWriter(w)
17+
18+
if err := csvWriter.WriteAll(ToCSVRecords(result)); err != nil {
19+
return fmt.Errorf("encoding csv: %w", err)
20+
}
21+
return nil
22+
}
23+
24+
// ToCSVRecords converts a ScanResult into a slice of CSV rows.
25+
func ToCSVRecords(result *models.ScanResult) [][]string {
26+
results := [][]string{}
27+
28+
for _, instance := range result.Instances {
29+
instance_id := instance.InstanceID
30+
name := instance.Name
31+
32+
// Map source enum to its string label.
33+
var source string
34+
switch instance.Source {
35+
case models.SourceEC2:
36+
source = "ec2"
37+
case models.SourceSageMakerEndpoint:
38+
source = "sagemaker-endpoint"
39+
case models.SourceSageMakerTraining:
40+
source = "sagemaker-training"
41+
case models.SourceEKS:
42+
source = "eks"
43+
case models.SourceK8sNode:
44+
source = "k8s-node"
45+
}
46+
47+
region := instance.Region
48+
instance_type := instance.InstanceType
49+
gpu_model := instance.GPUModel
50+
gpu_count := fmt.Sprintf("%d", instance.GPUCount)
51+
state := instance.State
52+
monthly_cost := fmt.Sprintf("%.4f", instance.MonthlyCost)
53+
estimated_savings := fmt.Sprintf("%.4f", instance.EstimatedSavings)
54+
55+
// Determine the highest severity across all waste signals.
56+
var severity string
57+
switch models.MaxSeverity(instance.WasteSignals) {
58+
case models.SeverityCritical:
59+
severity = "critical"
60+
case models.SeverityWarning:
61+
severity = "warning"
62+
case models.SeverityInfo:
63+
severity = "info"
64+
}
65+
66+
signal_type := instance.WasteSignals[0].Type
67+
68+
// Map the recommended action enum to its string label.
69+
var recommendation string
70+
switch instance.Recommendations[0].Action {
71+
case models.ActionTerminate:
72+
recommendation = "terminate"
73+
case models.ActionDownsize:
74+
recommendation = "downsize"
75+
case models.ActionChangePricing:
76+
recommendation = "change_pricing"
77+
case models.ActionSchedule:
78+
recommendation = "schedule"
79+
case models.ActionInvestigate:
80+
recommendation = "investigate"
81+
}
82+
83+
// Assemble and append the row.
84+
row := []string{instance_id, name, source, region, instance_type, gpu_model, gpu_count, state, monthly_cost, estimated_savings, severity, signal_type, recommendation}
85+
results = append(results, row)
86+
}
87+
return results
88+
}

internal/output/csv_test.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package output
2+
3+
import (
4+
"testing"
5+
"fmt"
6+
"os"
7+
"time"
8+
9+
"github.com/gpuaudit/cli/internal/models"
10+
)
11+
12+
// Shared test fixture: a single GPU instance with one waste signal and recommendation.
13+
var instance = models.GPUInstance{
14+
InstanceID: "i-1234567890abcdef0",
15+
Name: "test-instance",
16+
Source: models.SourceEC2,
17+
Region: "us-west-2",
18+
InstanceType: "p5.24xlarge",
19+
GPUModel: "NVIDIA A100",
20+
GPUCount: 8,
21+
State: "running",
22+
MonthlyCost: 24.00,
23+
EstimatedSavings: 12.00,
24+
WasteSignals: []models.WasteSignal{
25+
{
26+
Type: "underutilized",
27+
Severity: models.SeverityWarning,
28+
Confidence: 0.8,
29+
Evidence: "Average GPU utilization is 10%",
30+
},
31+
},
32+
Recommendations: []models.Recommendation{
33+
{
34+
Action: "downsize",
35+
},
36+
},
37+
}
38+
39+
// Shared test fixture: a scan result wrapping the test instance above.
40+
var result = &models.ScanResult{
41+
Timestamp: time.Now(),
42+
AccountID: "123456789012",
43+
Targets: []string{"ec2"},
44+
Regions: []string{"us-west-2"},
45+
ScanDuration: "60",
46+
Instances: []models.GPUInstance{instance},
47+
Summary: models.ScanSummary{
48+
TotalInstances: 1,
49+
TotalMonthlyCost: 24.00,
50+
TotalEstimatedWaste: 12.00,
51+
WastePercent: 50.0,
52+
CriticalCount: 0,
53+
WarningCount: 1,
54+
InfoCount: 0,
55+
HealthyCount: 0,
56+
},
57+
TargetSummaries: []models.TargetSummary{
58+
{
59+
Target: "ec2",
60+
TotalInstances: 1,
61+
TotalMonthlyCost: 24.00,
62+
TotalEstimatedWaste: 12.00,
63+
WastePercent: 50.0,
64+
CriticalCount: 0,
65+
WarningCount: 1,
66+
},
67+
},
68+
TargetErrors: []models.TargetErrorInfo{
69+
{
70+
Target: "sagemaker-endpoint",
71+
Error: "Access denied",
72+
},
73+
},
74+
}
75+
76+
// TestFormatCSV writes a scan result to a temp file and checks for no errors.
77+
func TestFormatCSV(t *testing.T) {
78+
fileName := "test_output.csv"
79+
file, err := os.OpenFile(fileName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
80+
if err != nil {
81+
t.Fatalf("failed to create test output file: %v", err)
82+
}
83+
defer file.Close()
84+
defer os.Remove(fileName) // Clean up after test
85+
86+
if err := FormatCSV(file, result); err != nil {
87+
t.Fatalf("FormatCSV failed: %v", err)
88+
}
89+
}
90+
91+
// TestToCSVRecords checks that the CSV output matches the expected row layout.
92+
func TestToCSVRecords(t *testing.T) {
93+
// Build the expected row using the same formatting logic as the production code.
94+
expected := [][]string{
95+
{
96+
instance.InstanceID,
97+
instance.Name,
98+
fmt.Sprintf("%s", instance.Source),
99+
instance.Region,
100+
instance.InstanceType,
101+
instance.GPUModel,
102+
fmt.Sprintf("%d", instance.GPUCount),
103+
instance.State,
104+
fmt.Sprintf("%.4f", instance.MonthlyCost),
105+
fmt.Sprintf("%.4f", instance.EstimatedSavings),
106+
"warning",
107+
instance.WasteSignals[0].Type,
108+
"downsize",
109+
},
110+
}
111+
112+
result := ToCSVRecords(result)
113+
114+
// Only checking length here; a deeper field-by-field check would be more thorough.
115+
if len(result) != len(expected) {
116+
t.Fatalf("expected: %v\ngot: %v", expected, result)
117+
}
118+
}

0 commit comments

Comments
 (0)