zernel/commands/
cost.rs

1// Copyright (C) 2026 Dyber, Inc. — Proprietary
2
3//! zernel cost — GPU cost tracking
4
5use anyhow::Result;
6use clap::Subcommand;
7
8#[derive(Subcommand)]
9pub enum CostCommands {
10    /// Show GPU usage summary
11    Summary,
12    /// Show cost by user
13    User {
14        /// Username to filter
15        name: Option<String>,
16    },
17    /// Show cost for a specific job
18    Job {
19        /// Job ID
20        id: String,
21    },
22    /// Generate cost report
23    Report {
24        /// Month (e.g., march, 2026-03)
25        #[arg(long)]
26        month: Option<String>,
27    },
28    /// Set GPU-hour budget with alerts
29    Budget {
30        /// GPU-hours budget
31        #[arg(long)]
32        set: Option<u64>,
33    },
34}
35
36pub async fn run(cmd: CostCommands) -> Result<()> {
37    let jobs_db = crate::experiments::tracker::zernel_dir()
38        .join("jobs")
39        .join("jobs.db");
40
41    match cmd {
42        CostCommands::Summary => {
43            println!("Zernel GPU Cost Summary");
44            println!("{}", "=".repeat(50));
45
46            if !jobs_db.exists() {
47                println!("No job data. Submit jobs with: zernel job submit <script>");
48                return Ok(());
49            }
50
51            let conn = rusqlite::Connection::open(&jobs_db)?;
52            let mut stmt = conn.prepare(
53                "SELECT COUNT(*), SUM(CASE WHEN status='done' THEN 1 ELSE 0 END), SUM(CASE WHEN status='failed' THEN 1 ELSE 0 END), SUM(gpus_per_node * nodes) FROM jobs",
54            )?;
55
56            let (total, done, failed, total_gpus): (u32, u32, u32, Option<u32>) = stmt
57                .query_row([], |row| {
58                    Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
59                })?;
60
61            println!("  Total jobs:     {total}");
62            println!("  Completed:      {done}");
63            println!("  Failed:         {failed}");
64            println!("  Total GPU-jobs: {}", total_gpus.unwrap_or(0));
65
66            // Estimate GPU-hours from experiments
67            let exp_db = crate::experiments::tracker::experiments_db_path();
68            if exp_db.exists() {
69                let exp_conn = rusqlite::Connection::open(&exp_db)?;
70                let total_secs: f64 = exp_conn
71                    .query_row(
72                        "SELECT COALESCE(SUM(duration_secs), 0) FROM experiments",
73                        [],
74                        |row| row.get(0),
75                    )
76                    .unwrap_or(0.0);
77                let gpu_hours = total_secs / 3600.0;
78                println!(
79                    "  Total GPU-hours: {gpu_hours:.1}h (estimated from experiment durations)"
80                );
81            }
82        }
83
84        CostCommands::User { name } => {
85            println!("GPU cost by user");
86            if let Some(n) = name {
87                println!("  Filtering for: {n}");
88            }
89            println!("  (per-user tracking requires auth — coming in enterprise edition)");
90        }
91
92        CostCommands::Job { id } => {
93            if !jobs_db.exists() {
94                println!("No job data.");
95                return Ok(());
96            }
97
98            let conn = rusqlite::Connection::open(&jobs_db)?;
99            let result: Result<(String, u32, u32, String), _> = conn.query_row(
100                "SELECT script, gpus_per_node, nodes, submitted_at FROM jobs WHERE id = ?1",
101                [&id],
102                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
103            );
104
105            match result {
106                Ok((script, gpus, nodes, submitted)) => {
107                    println!("Job: {id}");
108                    println!("  Script: {script}");
109                    println!("  GPUs: {gpus} x {nodes} nodes = {} total", gpus * nodes);
110                    println!("  Submitted: {submitted}");
111                }
112                Err(_) => println!("Job not found: {id}"),
113            }
114        }
115
116        CostCommands::Report { month } => {
117            let m = month.unwrap_or_else(|| "all time".into());
118            println!("GPU Cost Report — {m}");
119            println!();
120            println!("Run: zernel cost summary  — for current totals");
121            println!("(Detailed reports coming in enterprise edition)");
122        }
123
124        CostCommands::Budget { set } => {
125            if let Some(hours) = set {
126                let budget_file = crate::experiments::tracker::zernel_dir().join("gpu-budget.txt");
127                std::fs::write(&budget_file, hours.to_string())?;
128                println!("GPU-hour budget set to: {hours}h");
129                println!("Alerts will fire when 80% of budget is consumed.");
130            } else {
131                let budget_file = crate::experiments::tracker::zernel_dir().join("gpu-budget.txt");
132                if budget_file.exists() {
133                    let budget = std::fs::read_to_string(&budget_file)?;
134                    println!("Current budget: {budget}h");
135                } else {
136                    println!("No budget set. Set one: zernel cost budget --set 10000");
137                }
138            }
139        }
140    }
141    Ok(())
142}