okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
2d8bd88
main
1//// provide-usage as a stateless one-shot CLI, driven from Erlang. The core 2//// (usage-core, Zig) decides which requests to send and nothing else: each 3//// round is one fresh `usage advance -` process fed the whole history on 4//// stdin, so a credential never reaches argv, and every byte of I/O is 5//// albedo's - `http` requests through albedo's HTTP client, `command` 6//// requests through a PATH, HOME, locale, timezone and proxy environment 7//// with a timeout and a stdout cap. The round loop lives here and the I/O 8//// lives in albedo_usage_core, so neither half can drift from the other. 9//// 10//// `Error` from `fetch` means the driver itself failed: the binary is 11//// missing, its output is unparseable, or the feed did not finish in 12//// `max_rounds` rounds. A provider failure (a 4xx, an unknown provider, a 13//// flaky CLI) is data: it comes back as `Ok(Report(error: Some(..)))`. 14 15import gleam/dynamic/decode 16import gleam/int 17import gleam/json 18import gleam/list 19import gleam/option .{ type Option , None , Some } 20import gleam/result 21import gleam/string 22 23pub type Limit { 24Limit ( 25id : String , 26label : String , 27used_percent : Option ( Float ), 28window_label : Option ( String ), 29window_seconds : Option ( Int ), 30resets_at : Option ( Int ), 31scope : Option ( String ), 32status : String , 33) 34} 35 36pub type Report { 37Report ( 38account_id : Option ( String ), 39: Option ( String ), 40plan : Option ( String ), 41limits : List ( Limit ), 42error : Option ( String ), 43) 44} 45 46/// A feed that needs more round trips than this is stuck, not slow: the 47/// deepest feed today (hyper) is three rounds. 48const max_rounds = 6 49 50@external ( erlang , "albedo_usage_core" , "advance" ) 51fn advance ( envelope : String ) -> Result ( String , String ) 52 53@external ( erlang , "albedo_usage_core" , "http_request" ) 54fn http_request ( 55method : String , 56url : String , 57headers : List (#( String , String )), 58body : Option ( String ), 59) -> #( Int , String ) 60 61@external ( erlang , "albedo_usage_core" , "run_command" ) 62fn run_command ( 63command : String , 64args : List ( String ), 65stdin : Option ( String ), 66timeout_ms : Option ( Int ), 67) -> #( Int , String ) 68 69/// Runs a provider's feed to completion. Each round is one `usage advance -` 70/// process fed one envelope line on stdin (never argv: it carries the 71/// credential); `http` requests go through albedo's HTTP client, `command` 72/// requests (alibaba's `bl`) run with only PATH, HOME, locale, timezone and 73/// proxy variables, a timeout, and a stdout cap. 74pub fn fetch ( 75provider : String , 76credential : json . Json , 77now_ms : Int , 78) -> Result ( Report , String ) { 79loop ( provider , credential , now_ms , []) 80} 81 82/// `rounds` is the whole history so far, one entry per answered round, so 83/// every `usage advance -` process replays the feed from scratch and the core 84/// stays stateless: the host keeps the state, the core keeps the rules. 85fn loop ( 86provider : String , 87credential : json . Json , 88now_ms : Int , 89rounds : List ( json . Json ), 90) -> Result ( Report , String ) { 91case list . length ( rounds ) >= max_rounds { 92True -> 93Error ( 94"usage feed for " 95<> provider 96<> " did not finish in " 97<> int . to_string ( max_rounds ) 98<> " rounds" , 99) 100False -> { 101let envelope = 102json . object ([ 103#( "provider" , json . string ( provider )), 104#( "credential" , credential ), 105#( "responses" , json . array ( rounds , fn ( round ) { round })), 106#( "nowMs" , json . int ( now_ms )), 107]) 108|> json . to_string 109use output <- result . try ( advance ( envelope )) 110use step <- result . try ( step ( output )) 111case step { 112Reported ( report ) -> Ok ( report ) 113// The core answers an unknown provider (or its own parse failure) 114// with an error step, which is still the provider's answer, not a 115// driver failure. 116Failed ( message ) -> Ok ( Report ( None , None , None , [], Some ( message ))) 117Requests ( requests ) -> { 118let round = 119json . array ( list . map ( requests , answer ), fn ( response ) { response }) 120loop ( provider , credential , now_ms , list . append ( rounds , [ round ])) 121} 122} 123} 124} 125} 126 127fn answer ( request : Request ) -> json . Json { 128// A transport failure or a command that could not run is already folded 129// into the status by the Erlang half, so both request kinds answer the 130// same way: one `{"status", "body"}` object per request. 131let #( status , body ) = case request { 132HttpRequest ( method , url , headers , body ) -> 133http_request ( method , url , headers , body ) 134CommandRequest ( command , args , stdin , timeout_ms ) -> 135run_command ( command , args , stdin , timeout_ms ) 136} 137json . object ([ 138#( "status" , json . int ( status )), 139#( "body" , json . string ( body )), 140]) 141} 142 143/// One step is printed per replayed round; the last line is the answer. 144fn step ( output : String ) -> Result ( Step , String ) { 145let last = 146output 147|> string . split ( "\n" ) 148|> list . map ( string . trim ) 149|> list . filter ( fn ( line ) { line != "" }) 150|> list . last 151case last { 152Error ( Nil ) -> Error ( "the usage CLI printed no step" ) 153Ok ( last ) -> 154json . parse ( last , step_decoder ()) 155|> result . replace_error ( "the usage CLI printed an unparseable step" ) 156} 157} 158 159type Request { 160HttpRequest ( 161method : String , 162url : String , 163headers : List (#( String , String )), 164body : Option ( String ), 165) 166CommandRequest ( 167command : String , 168args : List ( String ), 169stdin : Option ( String ), 170timeout_ms : Option ( Int ), 171) 172} 173 174type Step { 175Requests ( List ( Request )) 176Reported ( Report ) 177Failed ( String ) 178} 179 180fn step_decoder () -> decode . Decoder ( Step ) { 181decode . one_of ( 182decode . field ( "report" , report_decoder (), fn ( report ) { 183decode . success ( Reported ( report )) 184}), 185or : [ 186decode . field ( "requests" , decode . list ( request_decoder ()), fn ( requests ) { 187decode . success ( Requests ( requests )) 188}), 189decode . field ( "error" , decode . string , fn ( message ) { 190decode . success ( Failed ( message )) 191}), 192], 193) 194} 195 196fn request_decoder () -> decode . Decoder ( Request ) { 197use kind <- decode . field ( "kind" , decode . string ) 198case kind { 199"command" -> command_decoder () 200_ -> http_decoder () 201} 202} 203 204fn command_decoder () -> decode . Decoder ( Request ) { 205use command <- decode . field ( "command" , decode . string ) 206use args <- decode . optional_field ( "args" , [], decode . list ( decode . string )) 207use stdin <- decode . optional_field ( "stdin" , None , optional_string ()) 208use timeout_ms <- decode . optional_field ( "timeoutMs" , None , optional_int ()) 209decode . success ( CommandRequest ( command , args , stdin , timeout_ms )) 210} 211 212fn http_decoder () -> decode . Decoder ( Request ) { 213use method <- decode . optional_field ( "method" , "GET" , decode . string ) 214use url <- decode . field ( "url" , decode . string ) 215use headers <- decode . optional_field ( "headers" , [], decode . list ( pair ())) 216use body <- decode . optional_field ( "body" , None , optional_string ()) 217decode . success ( HttpRequest ( method , url , headers , body )) 218} 219 220fn report_decoder () -> decode . Decoder ( Report ) { 221use account_id <- decode . optional_field ( "accountId" , None , optional_string ()) 222use <- decode . optional_field ( "email" , None , optional_string ()) 223use plan <- decode . optional_field ( "planType" , None , optional_string ()) 224use limits <- decode . optional_field ( 225"limits" , 226[], 227decode . list ( limit_decoder ()), 228) 229use error <- decode . optional_field ( "error" , None , optional_string ()) 230decode . success ( Report ( account_id , , plan , limits , error )) 231} 232 233fn limit_decoder () -> decode . Decoder ( Limit ) { 234use id <- decode . field ( "id" , decode . string ) 235use label <- decode . field ( "label" , decode . string ) 236use used_percent <- decode . optional_field ( 237"usedPercent" , 238None , 239optional_float (), 240) 241use window_label <- decode . optional_field ( 242"windowLabel" , 243None , 244optional_string (), 245) 246use window_seconds <- decode . optional_field ( 247"windowSeconds" , 248None , 249optional_int (), 250) 251use resets_at <- decode . optional_field ( "resetsAt" , None , optional_int ()) 252use scope <- decode . optional_field ( "scope" , None , optional_string ()) 253use status <- decode . field ( "status" , decode . string ) 254decode . success ( Limit ( 255id , 256label , 257used_percent , 258window_label , 259window_seconds , 260resets_at , 261scope , 262status , 263)) 264} 265 266fn optional_string () -> decode . Decoder ( Option ( String )) { 267decode . optional ( decode . string ) 268} 269 270fn optional_int () -> decode . Decoder ( Option ( Int )) { 271decode . optional ( decode . int ) 272} 273 274/// The core clamps percentages but emits whole numbers without a decimal 275/// point, which arrive as integers; a percentage is a float either way. 276fn optional_float () -> decode . Decoder ( Option ( Float )) { 277decode . optional ( 278decode . one_of ( decode . float , or : [ 279decode . map ( decode . int , int . to_float ), 280]), 281) 282} 283 284fn pair () -> decode . Decoder (#( String , String )) { 285decode . then ( decode . list ( decode . string ), fn ( parts ) { 286case parts { 287[ name , value ] -> decode . success (#( name , value )) 288_ -> decode . failure (#( "" , "" ), "a two-element header pair" ) 289} 290}) 291}