Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
184 linesCodeBlameRaw
1//! What g1t keeps and allows for a workspace, from wrangler vars, so an
2//! installation of its own can change them:
3//!
4//! - `LOG_RETENTION_DAYS` (90): a finished job's log is deleted this many
5//! days after it finished (retention.rs). `0` keeps logs for good.
6//! - `FREE_ACTIONS_STORAGE_BYTES` (2 GB): what a workspace without the
7//! plan may keep in artifacts and the cache together; past it, new
8//! uploads are refused (run_artifacts.rs, cache.rs). `0` is no cap.
9//! - `FREE_SELF_HOSTED_JOBS` (4): jobs a workspace without the plan may
10//! have running on its self-hosted runners at once (plan.rs). `0` is no
11//! cap.
12
13use worker::Env;
14
15pub const DEFAULT_LOG_RETENTION_DAYS: u64 = 90;
16pub const DEFAULT_FREE_ACTIONS_STORAGE_BYTES: u64 = 2 * 1024 * 1024 * 1024;
17pub const DEFAULT_FREE_SELF_HOSTED_JOBS: u64 = 4;
18
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub struct Limits {
21 pub log_retention_days: u64,
22 pub free_actions_storage_bytes: u64,
23 pub free_self_hosted_jobs: u64,
24}
25
26impl Default for Limits {
27 fn default() -> Self {
28 Limits {
29 log_retention_days: DEFAULT_LOG_RETENTION_DAYS,
30 free_actions_storage_bytes: DEFAULT_FREE_ACTIONS_STORAGE_BYTES,
31 free_self_hosted_jobs: DEFAULT_FREE_SELF_HOSTED_JOBS,
32 }
33 }
34}
35
36/// A var as a whole number, or `default` when it is unset or not one.
37pub fn number(raw: Option<String>, default: u64) -> u64 {
38 raw.and_then(|raw| raw.trim().parse().ok()).unwrap_or(default)
39}
40
41impl Limits {
42 pub fn from_env(env: &Env) -> Limits {
43 let var = |name: &str| env.var(name).ok().map(|value| value.to_string());
44 Limits {
45 log_retention_days: number(var("LOG_RETENTION_DAYS"), DEFAULT_LOG_RETENTION_DAYS),
46 free_actions_storage_bytes: number(var("FREE_ACTIONS_STORAGE_BYTES"), DEFAULT_FREE_ACTIONS_STORAGE_BYTES),
47 free_self_hosted_jobs: number(var("FREE_SELF_HOSTED_JOBS"), DEFAULT_FREE_SELF_HOSTED_JOBS),
48 }
49 }
50}
51
52/// `2 GB`, `1.5 GB`, `500 MB`: a cap as people read it.
53pub fn shown_bytes(bytes: u64) -> String {
54 const GB: u64 = 1024 * 1024 * 1024;
55 const MB: u64 = 1024 * 1024;
56 if bytes >= GB {
57 let tenths = (bytes * 10).div_ceil(GB);
58 if tenths % 10 == 0 { format!("{} GB", tenths / 10) } else { format!("{}.{} GB", tenths / 10, tenths % 10) }
59 } else {
60 format!("{} MB", bytes.div_ceil(MB))
61 }
62}
63
64#[cfg(test)]
65mod tests {
66 use super::*;
67
68 #[test]
69 fn vars_fall_back_to_their_defaults() {
70 assert_eq!(number(None, 90), 90);
71 assert_eq!(number(Some(" 30 ".into()), 90), 30);
72 assert_eq!(number(Some("0".into()), 90), 0);
73 assert_eq!(number(Some("ninety".into()), 90), 90);
74 assert_eq!(Limits::default().free_actions_storage_bytes, 2 * 1024 * 1024 * 1024);
75 }
76
77 #[test]
78 fn caps_read_in_gigabytes_or_megabytes() {
79 assert_eq!(shown_bytes(2 * 1024 * 1024 * 1024), "2 GB");
80 assert_eq!(shown_bytes(1536 * 1024 * 1024), "1.5 GB");
81 assert_eq!(shown_bytes(500 * 1024 * 1024), "500 MB");
82 }
83}
84
85/// Whether a workspace without the plan may start one more job on its
86/// self-hosted runners, with `running` going already. `cap` 0 is no cap.
87pub fn self_hosted_room(running: u64, cap: u64) -> bool {
88 cap == 0 || running < cap
89}
90
91impl crate::Actions {
92 /// The workspace's standing with billing: its plan, and any hold staff
93 /// put on it. None when billing does not answer: callers let things
94 /// through rather than stop everyone's work on billing's account.
95 pub(crate) async fn standing(&self, workspace: &str) -> Option<g1t_contracts::billing::Entitlements> {
96 let args = g1t_contracts::billing::EntitlementsArgs { workspace: workspace.to_lowercase() };
97 match g1t_kit::call(&self.billing, "entitlements", &args).await {
98 Ok(entitlements) => Some(entitlements),
99 Err(error) => {
100 worker::console_error!("actions: billing's entitlements for {workspace} failed: {error}");
101 None
102 }
103 }
104 }
105}
106
107/// What a free workspace is told when an upload would take its artifacts
108/// and cache past `cap`, holding `held` and adding `adding`. None while it
109/// fits, or with no cap.
110pub fn storage_refusal(held: u64, adding: u64, cap: u64) -> Option<String> {
111 (cap > 0 && held.saturating_add(adding) > cap).then(|| {
112 format!(
113 "This workspace's artifacts and cache hold {} of the {} a workspace on the free plan keeps, so this upload is not kept. Delete artifacts you no longer need, let old cache entries expire, or start the g1t plan, which has no such cap.",
114 shown_bytes(held),
115 shown_bytes(cap)
116 )
117 })
118}
119
120impl crate::Actions {
121 /// Why an upload of `adding` bytes to the workspace's artifacts or
122 /// cache is refused: only past FREE_ACTIONS_STORAGE_BYTES, and only
123 /// without the plan. Billing is asked only once the workspace is past
124 /// the cap, and when it does not answer the upload goes ahead.
125 pub(crate) async fn storage_refused(&self, namespace: &str, adding: u64) -> worker::Result<Option<String>> {
126 let cap = self.limits.free_actions_storage_bytes;
127 if cap == 0 {
128 return Ok(None);
129 }
130 #[derive(serde::Deserialize)]
131 struct Held {
132 bytes: Option<f64>,
133 }
134 let held = self
135 .db
136 .prepare(
137 "SELECT SUM(size) AS bytes FROM (
138 SELECT size FROM cache_entries WHERE namespace = ?1 AND status IN ('ready', 'pending')
139 UNION ALL SELECT size FROM artifacts WHERE namespace = ?1 AND status IN ('ready', 'pending')
140 )",
141 )
142 .bind(&[namespace.into()])?
143 .first::<Held>(None)
144 .await?
145 .and_then(|held| held.bytes)
146 .unwrap_or(0.0) as u64;
147 let Some(why) = storage_refusal(held, adding, cap) else {
148 return Ok(None);
149 };
150 Ok(match self.standing(namespace).await {
151 Some(standing) if !standing.has_plan => Some(why),
152 _ => None,
153 })
154 }
155}
156
157#[cfg(test)]
158mod storage_tests {
159 use super::storage_refusal;
160
161 const GB: u64 = 1024 * 1024 * 1024;
162
163 #[test]
164 fn a_free_workspace_keeps_two_gigabytes_of_artifacts_and_cache() {
165 assert_eq!(storage_refusal(GB, GB, 2 * GB), None);
166 let refused = storage_refusal(2 * GB - 10, 100, 2 * GB).unwrap();
167 assert!(refused.contains("of the 2 GB"), "{refused}");
168 assert!(refused.contains("start the g1t plan"), "{refused}");
169 assert_eq!(storage_refusal(100 * GB, GB, 0), None);
170 }
171}
172
173#[cfg(test)]
174mod room_tests {
175 use super::self_hosted_room;
176
177 #[test]
178 fn free_workspaces_run_a_few_self_hosted_jobs_at_once() {
179 assert!(self_hosted_room(0, 4));
180 assert!(self_hosted_room(3, 4));
181 assert!(!self_hosted_room(4, 4));
182 assert!(self_hosted_room(400, 0));
183 }
184}