Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
e8d4c49
Remove ARCHITECTURE.md reference from README
maksimov Apr 4, 2026
d10acc0
Add Makefile with cross-compilation targets
maksimov Apr 4, 2026
ff54c0e
Improve downsizing recommendations and widen table output
maksimov Apr 4, 2026
5c8f3b0
Wrap long recommendation text instead of truncating
maksimov Apr 4, 2026
ced3665
Let recommendation text flow without wrapping
maksimov Apr 4, 2026
0d335c1
Add deploy script and cross-compiled binaries to gitignore
maksimov Apr 4, 2026
d83ef4d
Send progress and warning messages to stderr instead of stdout
maksimov Apr 4, 2026
8f2abe5
Add --exclude-tag flag to filter out instances by tag
maksimov Apr 4, 2026
6ec9394
Add --min-idle-days to filter out recently idle instances
maksimov Apr 4, 2026
697d80d
Fix --min-idle-days to strip idle signals from multi-signal instances
maksimov Apr 4, 2026
5618b00
Replace --min-idle-days with --min-uptime-days to suppress all signal…
maksimov Apr 4, 2026
4efc6a8
Add Apache 2.0 copyright headers to all source files
maksimov Apr 5, 2026
7570f45
Update GitHub repository links in README
maksimov Apr 5, 2026
296cc65
Move module path to github.com/gpuaudit/gpuaudit
maksimov Apr 5, 2026
cb8d08e
Strip debug symbols to reduce binary size by ~35%
maksimov Apr 5, 2026
debdd3f
Rename module path to github.com/gpuaudit/cli
maksimov Apr 5, 2026
2983348
Make EC2 discovery failure non-fatal so SageMaker scan can still proceed
maksimov Apr 5, 2026
efab177
Add EKS GPU node group discovery
maksimov Apr 8, 2026
51c0012
Add Kubernetes API GPU node discovery
maksimov Apr 8, 2026
e5678f4
Shorten K8s node names to hostname only
maksimov Apr 8, 2026
308795a
Fall back to Karpenter and GPU Operator labels for GPU model
maksimov Apr 8, 2026
a630aa0
Add diff command design spec and implementation plan
maksimov Apr 14, 2026
f963144
Add diff package with Compare function and tests
maksimov Apr 15, 2026
cc63318
Add diff table and JSON output formatters
maksimov Apr 15, 2026
68abdfa
Add diff subcommand to compare two scan results
maksimov Apr 15, 2026
de3487f
Fix box alignment in diff table output
maksimov Apr 15, 2026
7f5cfb3
Fix misleading idle duration in K8s GPU node recommendations
maksimov Apr 15, 2026
39a4926
Update README with K8s scanning, diff command, and current output format
maksimov Apr 15, 2026
60cf644
Add multi-target scanning design spec
maksimov Apr 18, 2026
0330be7
Add multi-target scanning implementation plan
maksimov Apr 18, 2026
bf6ab49
Add TargetSummary and TargetErrorInfo model types for multi-target sc…
maksimov Apr 18, 2026
817eaac
Extract BuildSummary to summary.go and add BuildTargetSummaries
maksimov Apr 18, 2026
1f21c28
Implement ResolveTargets with STS AssumeRole for multi-account scanning
maksimov Apr 18, 2026
6bb43ea
Refactor Scan() for parallel multi-target scanning
maksimov Apr 18, 2026
ce75ab1
Add --targets, --role, --org, --external-id, --skip-self flags to sca…
maksimov Apr 18, 2026
1906f9f
Add per-target summary table and target column to table formatter
maksimov Apr 18, 2026
5328980
Add per-target summaries to markdown and Slack formatters
maksimov Apr 18, 2026
2e54bd0
Add cross-account and Organizations permissions to iam-policy output
maksimov Apr 18, 2026
f824823
Add multi-account scanning docs to README
maksimov Apr 18, 2026
ebd0806
Fix callerAccount bug, deduplicate severity logic, clean up dead code
maksimov Apr 18, 2026
45b2457
Merge pull request #17 from gpuaudit/feature/diff-command-only
maksimov Apr 18, 2026
7f18339
Add SpotHourlyCost field to GPUInstance model
maksimov Apr 19, 2026
8acbdf2
Implement EnrichSpotPrices with DescribeSpotPriceHistory
maksimov Apr 19, 2026
0b2bbf5
Wire EnrichSpotPrices into scanRegion after EC2 discovery
maksimov Apr 19, 2026
d29c126
Correct spot instance cost using live spot prices
maksimov Apr 19, 2026
c8f4330
Add ruleSpotEligible analysis rule for spot recommendations
maksimov Apr 19, 2026
cb18d63
Add ec2:DescribeSpotPriceHistory to IAM policy output
maksimov Apr 19, 2026
6e39bbb
Address review: update signal type comment, add pagination note, guar…
maksimov Apr 19, 2026
2043078
Merge pull request #18 from gpuaudit/feature/multi-account-scanning
maksimov Apr 19, 2026
22cf265
Add K8s GPU metrics collection design spec
maksimov Apr 19, 2026
ee8e309
Add K8s GPU metrics collection implementation plan
maksimov Apr 19, 2026
879f2c1
Add EnrichK8sGPUMetrics for CloudWatch Container Insights GPU metrics
maksimov Apr 19, 2026
9a176fa
Add ProxyGet to K8sClient interface for pod API proxy
maksimov Apr 19, 2026
4f54360
Add DCGM exporter scraping for K8s GPU metrics
maksimov Apr 19, 2026
98003ef
Add Prometheus query enrichment for K8s GPU metrics
maksimov Apr 19, 2026
1a35f95
Add ruleK8sLowGPUUtil for utilization-based K8s GPU waste detection
maksimov Apr 19, 2026
89d9cb3
Wire K8s GPU metrics fallback chain into CLI scan flow
maksimov Apr 19, 2026
c4dff65
Fix DCGM node matching and CW error spam
maksimov Apr 19, 2026
d89df5f
Fix DCGM scrape spam and Prometheus node name mismatch
maksimov Apr 19, 2026
fa00dff
Include time window in low GPU utilization recommendation text
maksimov Apr 19, 2026
51db9f4
Skip CW enrichment when AWS creds unavailable, reduce DCGM noise
maksimov Apr 19, 2026
2fcb210
Merge pull request #19 from gpuaudit/feature/spot-recommendations
maksimov Apr 19, 2026
0a1b2a1
Merge origin/master into feature/k8s-gpu-metrics
maksimov Apr 19, 2026
29c8fcc
Merge pull request #21 from gpuaudit/feature/k8s-gpu-metrics
maksimov Apr 19, 2026
fd998a9
Add support for csv output format. Implementation for FormatCSV as we…
sospeter-57 Apr 21, 2026
17900fa
Add CSV output formatter and tests
sospeter-57 Apr 22, 2026
4a7e24a
Merge upstream/master into feature branch
sospeter-57 Apr 22, 2026
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
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)
}
}