Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cmd/gpuaudit/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
88 changes: 88 additions & 0 deletions internal/output/csv.go
Original file line number Diff line number Diff line change
@@ -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
}
118 changes: 118 additions & 0 deletions internal/output/csv_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}