okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawnRecord provider calls and account quota for cache-aware compaction2d8bd88

main
8.7 KiB291 linesraw
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 {
24  Limit(
25    id: String,
26    label: String,
27    used_percent: Option(Float),
28    window_label: Option(String),
29    window_seconds: Option(Int),
30    resets_at: Option(Int),
31    scope: Option(String),
32    status: String,
33  )
34}
35
36pub type Report {
37  Report(
38    account_id: Option(String),
39    email: Option(String),
40    plan: Option(String),
41    limits: List(Limit),
42    error: 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(
55  method: String,
56  url: String,
57  headers: List(#(String, String)),
58  body: Option(String),
59) -> #(Int, String)
60
61@external(erlang, "albedo_usage_core", "run_command")
62fn run_command(
63  command: String,
64  args: List(String),
65  stdin: Option(String),
66  timeout_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(
75  provider: String,
76  credential: json.Json,
77  now_ms: Int,
78) -> Result(Report, String) {
79  loop(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(
86  provider: String,
87  credential: json.Json,
88  now_ms: Int,
89  rounds: List(json.Json),
90) -> Result(Report, String) {
91  case list.length(rounds) >= max_rounds {
92    True ->
93      Error(
94        "usage feed for "
95        <> provider
96        <> " did not finish in "
97        <> int.to_string(max_rounds)
98        <> " rounds",
99      )
100    False -> {
101      let envelope =
102        json.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
109      use output <- result.try(advance(envelope))
110      use step <- result.try(step(output))
111      case step {
112        Reported(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.
116        Failed(message) -> Ok(Report(None, None, None, [], Some(message)))
117        Requests(requests) -> {
118          let round =
119            json.array(list.map(requests, answer), fn(response) { response })
120          loop(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.
131  let #(status, body) = case request {
132    HttpRequest(method, url, headers, body) ->
133      http_request(method, url, headers, body)
134    CommandRequest(command, args, stdin, timeout_ms) ->
135      run_command(command, args, stdin, timeout_ms)
136  }
137  json.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) {
145  let last =
146    output
147    |> string.split("\n")
148    |> list.map(string.trim)
149    |> list.filter(fn(line) { line != "" })
150    |> list.last
151  case last {
152    Error(Nil) -> Error("the usage CLI printed no step")
153    Ok(last) ->
154      json.parse(last, step_decoder())
155      |> result.replace_error("the usage CLI printed an unparseable step")
156  }
157}
158
159type Request {
160  HttpRequest(
161    method: String,
162    url: String,
163    headers: List(#(String, String)),
164    body: Option(String),
165  )
166  CommandRequest(
167    command: String,
168    args: List(String),
169    stdin: Option(String),
170    timeout_ms: Option(Int),
171  )
172}
173
174type Step {
175  Requests(List(Request))
176  Reported(Report)
177  Failed(String)
178}
179
180fn step_decoder() -> decode.Decoder(Step) {
181  decode.one_of(
182    decode.field("report", report_decoder(), fn(report) {
183      decode.success(Reported(report))
184    }),
185    or: [
186      decode.field("requests", decode.list(request_decoder()), fn(requests) {
187        decode.success(Requests(requests))
188      }),
189      decode.field("error", decode.string, fn(message) {
190        decode.success(Failed(message))
191      }),
192    ],
193  )
194}
195
196fn request_decoder() -> decode.Decoder(Request) {
197  use kind <- decode.field("kind", decode.string)
198  case kind {
199    "command" -> command_decoder()
200    _ -> http_decoder()
201  }
202}
203
204fn command_decoder() -> decode.Decoder(Request) {
205  use command <- decode.field("command", decode.string)
206  use args <- decode.optional_field("args", [], decode.list(decode.string))
207  use stdin <- decode.optional_field("stdin", None, optional_string())
208  use timeout_ms <- decode.optional_field("timeoutMs", None, optional_int())
209  decode.success(CommandRequest(command, args, stdin, timeout_ms))
210}
211
212fn http_decoder() -> decode.Decoder(Request) {
213  use method <- decode.optional_field("method", "GET", decode.string)
214  use url <- decode.field("url", decode.string)
215  use headers <- decode.optional_field("headers", [], decode.list(pair()))
216  use body <- decode.optional_field("body", None, optional_string())
217  decode.success(HttpRequest(method, url, headers, body))
218}
219
220fn report_decoder() -> decode.Decoder(Report) {
221  use account_id <- decode.optional_field("accountId", None, optional_string())
222  use email <- decode.optional_field("email", None, optional_string())
223  use plan <- decode.optional_field("planType", None, optional_string())
224  use limits <- decode.optional_field(
225    "limits",
226    [],
227    decode.list(limit_decoder()),
228  )
229  use error <- decode.optional_field("error", None, optional_string())
230  decode.success(Report(account_id, email, plan, limits, error))
231}
232
233fn limit_decoder() -> decode.Decoder(Limit) {
234  use id <- decode.field("id", decode.string)
235  use label <- decode.field("label", decode.string)
236  use used_percent <- decode.optional_field(
237    "usedPercent",
238    None,
239    optional_float(),
240  )
241  use window_label <- decode.optional_field(
242    "windowLabel",
243    None,
244    optional_string(),
245  )
246  use window_seconds <- decode.optional_field(
247    "windowSeconds",
248    None,
249    optional_int(),
250  )
251  use resets_at <- decode.optional_field("resetsAt", None, optional_int())
252  use scope <- decode.optional_field("scope", None, optional_string())
253  use status <- decode.field("status", decode.string)
254  decode.success(Limit(
255    id,
256    label,
257    used_percent,
258    window_label,
259    window_seconds,
260    resets_at,
261    scope,
262    status,
263  ))
264}
265
266fn optional_string() -> decode.Decoder(Option(String)) {
267  decode.optional(decode.string)
268}
269
270fn optional_int() -> decode.Decoder(Option(Int)) {
271  decode.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)) {
277  decode.optional(
278    decode.one_of(decode.float, or: [
279      decode.map(decode.int, int.to_float),
280    ]),
281  )
282}
283
284fn pair() -> decode.Decoder(#(String, String)) {
285  decode.then(decode.list(decode.string), fn(parts) {
286    case parts {
287      [name, value] -> decode.success(#(name, value))
288      _ -> decode.failure(#("", ""), "a two-element header pair")
289    }
290  })
291}