| 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 | |
| 13 | use worker::Env; |
| 14 | |
| 15 | pub const DEFAULT_LOG_RETENTION_DAYS: u64 = 90; |
| 16 | pub const DEFAULT_FREE_ACTIONS_STORAGE_BYTES: u64 = 2 * 1024 * 1024 * 1024; |
| 17 | pub const DEFAULT_FREE_SELF_HOSTED_JOBS: u64 = 4; |
| 18 | |
| 19 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 20 | pub struct Limits { |
| 21 | pub log_retention_days: u64, |
| 22 | pub free_actions_storage_bytes: u64, |
| 23 | pub free_self_hosted_jobs: u64, |
| 24 | } |
| 25 | |
| 26 | impl 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. |
| 37 | pub fn number(raw: Option<String>, default: u64) -> u64 { |
| 38 | raw.and_then(|raw| raw.trim().parse().ok()).unwrap_or(default) |
| 39 | } |
| 40 | |
| 41 | impl 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. |
| 53 | pub 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)] |
| 65 | mod 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. |
| 87 | pub fn self_hosted_room(running: u64, cap: u64) -> bool { |
| 88 | cap == 0 || running < cap |
| 89 | } |
| 90 | |
| 91 | impl 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. |
| 110 | pub 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 | |
| 120 | impl 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)] |
| 158 | mod 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)] |
| 174 | mod 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 | } |