pr_01m47d24b0e6n91zwymwxg0vpx/services/repos/src/git_ops.rs
| 1 | //! Git operations through g1t's git endpoints, counted per workspace. |
| 2 | //! |
| 3 | //! Cloudflare Artifacts charges g1t for every operation from 2026-10-14 |
| 4 | //! ($0.15 per 1,000): each clone, fetch and push. Every upload-pack (clone |
| 5 | //! or fetch) and receive-pack (push) request through here is one, counted |
| 6 | //! by the hour. Billing reads the month's count each day (`git_operations`) |
| 7 | //! and charges workspaces on the plan for what is past the included amount. |
| 8 | //! |
| 9 | //! A free workspace is never charged for git operations. Past |
| 10 | //! `GIT_OPERATIONS_FREE_CAP` in a month (50,000, five times what is |
| 11 | //! included), it is slowed down instead: at most |
| 12 | //! `GIT_OPERATIONS_FREE_HOURLY` (60) an hour, answered 429 with when to try |
| 13 | //! again. Pushes from agents' sandboxes go to the store directly and are |
| 14 | //! not counted. |
| 15 | |
| 16 | use g1t_contracts::repos::WorkspaceGitOperations; |
| 17 | use serde::Deserialize; |
| 18 | use worker::wasm_bindgen::JsValue; |
| 19 | use worker::{D1Database, Env, Fetcher, Response, Result}; |
| 20 | |
| 21 | /// The hour an operation is counted in: `YYYY-MM-DDTHH` of an RFC 3339 time. |
| 22 | pub fn hour_key(timestamp: &str) -> String { |
| 23 | timestamp[..13].to_owned() |
| 24 | } |
| 25 | |
| 26 | /// Whether a free workspace's operation should wait: past the month's cap, |
| 27 | /// and past the hour's share. |
| 28 | pub fn slow_down(month_ops: u64, hour_ops: u64, free_cap: u64, hourly: u64) -> bool { |
| 29 | month_ops > free_cap && hour_ops > hourly |
| 30 | } |
| 31 | |
| 32 | /// The limits, from the repos service's variables. |
| 33 | pub struct Limits { |
| 34 | pub free_cap: u64, |
| 35 | pub hourly: u64, |
| 36 | } |
| 37 | |
| 38 | impl Limits { |
| 39 | pub fn from_env(env: &Env) -> Self { |
| 40 | let number = |name: &str, default: u64| env.var(name).ok().and_then(|v| v.to_string().parse().ok()).unwrap_or(default); |
| 41 | Limits { free_cap: number("GIT_OPERATIONS_FREE_CAP", 50_000), hourly: number("GIT_OPERATIONS_FREE_HOURLY", 60) } |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | #[derive(Deserialize)] |
| 46 | struct Counts { |
| 47 | month: Option<f64>, |
| 48 | hour: Option<f64>, |
| 49 | } |
| 50 | |
| 51 | /// Counts one operation for `namespace` now; returns the month's and the |
| 52 | /// hour's counts with it. |
| 53 | pub async fn count(db: &D1Database, namespace: &str, now: &str) -> Result<(u64, u64)> { |
| 54 | let hour = hour_key(now); |
| 55 | let month = &now[..7]; |
| 56 | let results = db |
| 57 | .batch(vec![ |
| 58 | db.prepare( |
| 59 | "INSERT INTO git_operations (namespace, hour, operations) VALUES (?1, ?2, 1) |
| 60 | ON CONFLICT (namespace, hour) DO UPDATE SET operations = operations + 1", |
| 61 | ) |
| 62 | .bind(&[namespace.into(), hour.as_str().into()])?, |
| 63 | db.prepare( |
| 64 | "SELECT SUM(operations) AS month, SUM(CASE WHEN hour = ?2 THEN operations END) AS hour |
| 65 | FROM git_operations WHERE namespace = ?1 AND substr(hour, 1, 7) = ?3", |
| 66 | ) |
| 67 | .bind(&[namespace.into(), hour.as_str().into(), month.into()])?, |
| 68 | ]) |
| 69 | .await?; |
| 70 | let counts = results.get(1).map(|r| r.results::<Counts>()).transpose()?.and_then(|rows| rows.into_iter().next()); |
| 71 | Ok(counts.map_or((1, 1), |c| (c.month.unwrap_or(1.0) as u64, c.hour.unwrap_or(1.0) as u64))) |
| 72 | } |
| 73 | |
| 74 | /// Each workspace's operations in `month`, from `since` (an hour) on. |
| 75 | pub async fn totals(db: &D1Database, month: &str, since: Option<&str>, namespace: Option<&str>) -> Result<Vec<WorkspaceGitOperations>> { |
| 76 | #[derive(Deserialize)] |
| 77 | struct Row { |
| 78 | namespace: String, |
| 79 | operations: Option<f64>, |
| 80 | } |
| 81 | Ok(db |
| 82 | .prepare( |
| 83 | "SELECT namespace, SUM(operations) AS operations FROM git_operations |
| 84 | WHERE substr(hour, 1, 7) = ?1 AND hour >= COALESCE(?2, '') AND (?3 IS NULL OR namespace = ?3) |
| 85 | GROUP BY namespace", |
| 86 | ) |
| 87 | .bind(&[month.into(), since.map_or(JsValue::NULL, JsValue::from), namespace.map_or(JsValue::NULL, JsValue::from)])? |
| 88 | .all() |
| 89 | .await? |
| 90 | .results::<Row>()? |
| 91 | .into_iter() |
| 92 | .map(|row| WorkspaceGitOperations { namespace: row.namespace, operations: row.operations.unwrap_or(0.0) as u64 }) |
| 93 | .collect()) |
| 94 | } |
| 95 | |
| 96 | /// Whether billing says the workspace is free. Unknown (billing not bound |
| 97 | /// or not answering) counts as not free: nothing is slowed down on a guess. |
| 98 | pub async fn is_free(billing: Option<&Fetcher>, namespace: &str) -> bool { |
| 99 | let Some(billing) = billing else { return false }; |
| 100 | let args = g1t_contracts::billing::EntitlementsArgs { workspace: namespace.to_owned() }; |
| 101 | match g1t_kit::call::<_, serde_json::Value>(billing, "entitlements", &args).await { |
| 102 | Ok(found) => found["plan"].as_str() == Some("free"), |
| 103 | Err(error) => { |
| 104 | worker::console_error!("could not ask billing about {namespace}: {error}"); |
| 105 | false |
| 106 | } |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | /// The private storage a free workspace may push to: 1 GB unless set. |
| 111 | pub fn free_private_bytes(env: &worker::Env) -> i64 { |
| 112 | env.var("FREE_PRIVATE_STORAGE_BYTES") |
| 113 | .ok() |
| 114 | .and_then(|value| value.to_string().parse().ok()) |
| 115 | .unwrap_or(1_000_000_000) |
| 116 | } |
| 117 | |
| 118 | /// Whether a push to a private repository should be refused: a free |
| 119 | /// workspace whose private repositories already hold its free amount. Free |
| 120 | /// workspaces are never charged for storage; past it, pushes stop instead. |
| 121 | pub fn storage_full(private_bytes: i64, free_bytes: i64) -> bool { |
| 122 | private_bytes >= free_bytes |
| 123 | } |
| 124 | |
| 125 | /// The answer to a push a free workspace has no room for. Plain text on |
| 126 | /// the push's first request, which git shows as the reason. |
| 127 | pub fn storage_full_response(namespace: &str, private_bytes: i64, free_bytes: i64) -> Result<Response> { |
| 128 | let gb = |bytes: i64| bytes as f64 / 1_000_000_000.0; |
| 129 | let message = format!( |
| 130 | "{namespace}'s private repositories hold {:.2} GB, and a free workspace has {:.0} GB. Free workspaces are never charged for storage, so pushes to private repositories stop here. Make the repository public, delete what you no longer need, or start the g1t plan (10 GB): https://g1t.sh/{namespace}/-/billing |
| 131 | ", |
| 132 | gb(private_bytes), |
| 133 | gb(free_bytes) |
| 134 | ); |
| 135 | Response::error(message, 403) |
| 136 | } |
| 137 | |
| 138 | /// The answer to a free workspace past its share: try again next hour. |
| 139 | pub fn too_many(namespace: &str, free_cap: u64, hourly: u64) -> Result<Response> { |
| 140 | let message = format!( |
| 141 | "{namespace} has made more than {free_cap} git operations this month, so g1t allows {hourly} an hour until the month turns. Free workspaces are never charged for git operations; the g1t plan has no hourly limit: https://g1t.sh/{namespace}/-/billing\n" |
| 142 | ); |
| 143 | let response = Response::error(message, 429)?; |
| 144 | response.headers().set("retry-after", "3600")?; |
| 145 | Ok(response) |
| 146 | } |
| 147 | |
| 148 | #[cfg(test)] |
| 149 | mod tests { |
| 150 | use super::*; |
| 151 | |
| 152 | #[test] |
| 153 | fn operations_are_counted_by_the_hour() { |
| 154 | assert_eq!(hour_key("2026-10-14T09:59:59.000Z"), "2026-10-14T09"); |
| 155 | } |
| 156 | |
| 157 | #[test] |
| 158 | fn a_free_workspace_pushes_until_its_private_storage_is_full() { |
| 159 | assert!(!storage_full(999_999_999, 1_000_000_000)); |
| 160 | assert!(storage_full(1_000_000_000, 1_000_000_000)); |
| 161 | assert!(storage_full(3_000_000_000, 1_000_000_000)); |
| 162 | } |
| 163 | |
| 164 | #[test] |
| 165 | fn a_free_workspace_is_slowed_only_past_its_monthly_cap() { |
| 166 | // Under the cap: never slowed, however busy the hour. |
| 167 | assert!(!slow_down(49_999, 5_000, 50_000, 60)); |
| 168 | // Past it: 60 an hour, then wait. |
| 169 | assert!(!slow_down(50_001, 60, 50_000, 60)); |
| 170 | assert!(slow_down(50_001, 61, 50_000, 60)); |
| 171 | } |
| 172 | } |