Skip to main content

hws/
types.rs

1//! Read-side views of the admin API's JSON.
2//!
3//! Deliberately lenient: every field defaults, so a client a version behind the
4//! app-lb it is talking to still renders what it understands instead of failing
5//! to parse. Writes go through `serde_json::Value` so nothing is dropped on a
6//! round trip — see [`crate::api`].
7//!
8//! # `extra`, and why leniency needed a counterweight
9//!
10//! Leniency alone is how these types silently fell behind: to a defaulting
11//! deserializer an unknown field and an absent one are the same thing, so
12//! `DeploymentView::urls`, `PoolStatus::boot_timeout_secs`,
13//! `MetricsResponse::matched` and two more went missing without a single test
14//! failing.
15//!
16//! Every response type therefore carries a `#[serde(flatten)] extra` map. It
17//! keeps the leniency — an unknown field parses fine, and is *reachable* rather
18//! than discarded — and it makes the gap visible: the tests in
19//! `tests/wire_contract.rs` read `testdata/wire/*.json`, written by app-lb's own
20//! response types, and assert `extra` is empty. A field this crate stops
21//! understanding fails a test instead of blanking a column.
22
23use serde::Deserialize;
24use serde_json::Value;
25use std::collections::BTreeMap;
26
27/// Fields the server sent that this build has no name for.
28///
29/// Empty in a matched pair of versions. Non-empty means app-lb is ahead, and
30/// what is in here is exactly what this crate is not yet reading.
31pub type Extra = serde_json::Map<String, Value>;
32
33// -- GET /deployments, GET /deployments/:id --------------------------------
34
35#[derive(Debug, Default, Deserialize)]
36#[serde(default)]
37pub struct DeploymentStatus {
38    pub rollout_revision: String,
39    pub spec: DeploymentSpec,
40    /// `"vm"` (managed pool), `"static"` (fixed proxy_pass upstreams) or
41    /// `"site"` (files served off disk).
42    pub kind: String,
43    pub desired_replicas: u32,
44    pub ready: usize,
45    pub pending: usize,
46    pub total_in_flight: usize,
47    pub vms: Vec<VmStatus>,
48    /// Present when the spec declares `vm.workspace`.
49    pub workspace: Option<WorkspaceStatus>,
50    /// Present for a site: whether its root on the LB host can serve.
51    pub site: Option<SiteRootStatus>,
52    #[serde(flatten)]
53    pub extra: Extra,
54}
55
56/// A site's root as the LB host sees it.
57#[derive(Debug, Default, Clone, Deserialize)]
58#[serde(default)]
59pub struct SiteRootStatus {
60    pub root: String,
61    /// `ok`, `missing`, `not_a_directory`, `unreadable` or `empty`.
62    pub status: String,
63    pub index_present: Option<bool>,
64    /// Why it cannot serve, and how to fill it.
65    pub hint: Option<String>,
66}
67
68/// `vm.workspace`, mirrored read-only like [`MountSpec`].
69#[derive(Debug, Default, Clone, Deserialize)]
70#[serde(default)]
71pub struct WorkspaceSpec {
72    /// Defaults to `/workspace` server-side.
73    pub path: Option<String>,
74    pub store: String,
75    #[serde(rename = "ref")]
76    pub artifact_ref: Option<String>,
77    pub auth: Option<SecretRef>,
78    /// Recycle the replica for a snapshot at least this often. Unset: only
79    /// when it retires for another reason.
80    pub snapshot_interval_secs: Option<u64>,
81}
82
83/// Where a deployment's workspace stands: the snapshot its pool runs from,
84/// whether the store has it, and what is holding the pool if anything is.
85#[derive(Debug, Default, Clone, Deserialize)]
86#[serde(default)]
87pub struct WorkspaceStatus {
88    pub path: String,
89    pub store: String,
90    pub digest: Option<String>,
91    pub captured_at: Option<u64>,
92    pub captured_from: Option<String>,
93    pub files: u64,
94    pub bytes: u64,
95    pub pushed: Option<String>,
96    pub pushed_at: Option<u64>,
97    pub push_pending: bool,
98    /// `idle`, `restoring`, `capturing`, `pushing` or `blocked`.
99    pub phase: String,
100    pub blocked: Option<String>,
101    pub pending: Vec<PendingCapture>,
102    pub last_error: Option<String>,
103}
104
105#[derive(Debug, Default, Clone, Deserialize)]
106#[serde(default)]
107pub struct PendingCapture {
108    pub sandbox_id: String,
109    pub then: String,
110    pub queued_at: u64,
111    pub attempts: u32,
112}
113
114#[derive(Debug, Default, Deserialize)]
115#[serde(default)]
116pub struct VmStatus {
117    pub sandbox_id: String,
118    pub addr: String,
119    pub in_flight: usize,
120    pub healthy: bool,
121    pub draining: bool,
122    #[serde(flatten)]
123    pub extra: Extra,
124}
125
126impl VmStatus {
127    /// The single-word status column, in precedence order: a draining VM is
128    /// still serving, so that fact outranks its health.
129    pub fn status(&self) -> &'static str {
130        if self.draining {
131            "Draining"
132        } else if self.healthy {
133            "Ready"
134        } else {
135            "NotReady"
136        }
137    }
138}
139
140#[derive(Debug, Default, Deserialize)]
141#[serde(default)]
142pub struct DeploymentSpec {
143    pub id: String,
144    /// The namespace wall this deployment sits behind. Omitted on the wire when
145    /// it is `default` — a single-tenant app-lb's specs look exactly as they did
146    /// before namespaces — so read it through
147    /// [`namespace()`](DeploymentSpec::namespace) rather than directly.
148    pub namespace: String,
149    /// The heyo account this deployment's VMs are metered to, and the user
150    /// who registered it. Set by the managed service; absent on a
151    /// self-hosted app-lb.
152    pub account_id: Option<String>,
153    pub user_id: Option<String>,
154    pub routes: Vec<RouteRule>,
155    pub maintenance: bool,
156    pub vm: Option<VmSpec>,
157    pub scaling: ScalingPolicy,
158    pub health: HealthCheck,
159    pub upstreams: Vec<String>,
160    pub discovery: Option<DiscoverySpec>,
161    pub build: Option<BuildSpec>,
162    pub artifact: Option<ArtifactSpec>,
163    pub update: Option<UpdateSpec>,
164    pub auth: Option<AuthGate>,
165    pub site: Option<SiteSpec>,
166}
167
168impl DeploymentSpec {
169    /// The namespace this deployment is in, filling in the default the server
170    /// omits.
171    pub fn namespace(&self) -> &str {
172        if self.namespace.is_empty() {
173            crate::DEFAULT_NAMESPACE
174        } else {
175            &self.namespace
176        }
177    }
178}
179
180#[derive(Debug, Default, Deserialize)]
181#[serde(default)]
182pub struct DiscoverySpec {
183    pub service_id: String,
184}
185
186/// A static site: a directory on the app-lb host, served straight off disk.
187#[derive(Debug, Default, Deserialize)]
188#[serde(default)]
189pub struct SiteSpec {
190    pub root: String,
191    pub index: String,
192    pub not_found: Option<String>,
193    pub spa: bool,
194    pub cache_control: String,
195}
196
197impl DeploymentSpec {
198    /// Forwards to fixed upstreams. **Not** true for a site, which has no
199    /// backends of any kind.
200    pub fn is_static(&self) -> bool {
201        !self.upstreams.is_empty() || self.discovery.is_some()
202    }
203
204    /// Serves files off disk rather than proxying anywhere.
205    pub fn is_site(&self) -> bool {
206        self.site.is_some()
207    }
208
209    /// The routes as one column: `secrets.local`, `*.apps.example.com/api`, …
210    pub fn routes_summary(&self) -> String {
211        if self.routes.is_empty() {
212            return "<none>".into();
213        }
214        let shown: Vec<String> = self.routes.iter().take(3).map(RouteRule::render).collect();
215        let extra = self.routes.len().saturating_sub(shown.len());
216        if extra > 0 {
217            format!("{} +{extra} more", shown.join(","))
218        } else {
219            shown.join(",")
220        }
221    }
222
223    /// What traffic actually lands on: the image for a managed pool, the
224    /// upstream list for a static one, the directory for a site.
225    pub fn backend_summary(&self) -> String {
226        if let Some(site) = &self.site {
227            return site.root.clone();
228        }
229        if self.is_static() {
230            if let Some(discovery) = &self.discovery {
231                let upstreams = self.upstreams.join(",");
232                return if upstreams.is_empty() {
233                    format!("discovery:{}", discovery.service_id)
234                } else {
235                    format!("discovery:{} ({upstreams})", discovery.service_id)
236                };
237            }
238            return self.upstreams.join(",");
239        }
240        match &self.vm {
241            Some(vm) => {
242                let image = vm.image.as_deref().unwrap_or("ubuntu:24.04 (default)");
243                format!("{image}:{}", vm.port)
244            }
245            None => "<none>".into(),
246        }
247    }
248}
249
250#[derive(Debug, Default, Clone, Deserialize)]
251#[serde(default)]
252pub struct RouteRule {
253    pub host: Option<String>,
254    pub host_suffix: Option<String>,
255    pub path_prefix: Option<String>,
256    pub strip_prefix: bool,
257    pub redirect: Option<RouteRedirect>,
258}
259
260#[derive(Debug, Clone, Deserialize)]
261pub struct RouteRedirect {
262    pub to: String,
263    #[serde(default = "default_redirect_status")]
264    pub status: u16,
265    #[serde(default = "default_keep_path")]
266    pub keep_path: bool,
267}
268
269fn default_redirect_status() -> u16 {
270    301
271}
272
273fn default_keep_path() -> bool {
274    true
275}
276
277impl RouteRule {
278    /// One rule as a single token. A `host_suffix` is shown with the `*.` that
279    /// its semantics imply (apex *and* any subdomain).
280    pub fn render(&self) -> String {
281        let mut s = String::new();
282        if let Some(h) = &self.host {
283            s.push_str(h);
284        }
285        if let Some(suffix) = &self.host_suffix {
286            if !s.is_empty() {
287                s.push('+');
288            }
289            s.push_str("*.");
290            s.push_str(suffix.trim_start_matches('.'));
291        }
292        if let Some(p) = &self.path_prefix {
293            if s.is_empty() {
294                s.push('*');
295            }
296            s.push_str(p);
297        }
298        if s.is_empty() {
299            s.push_str("<empty>");
300        }
301        if let Some(r) = &self.redirect {
302            let path = if r.keep_path { "/…" } else { "" };
303            s.push_str(&format!(" → {} {}{path}", r.status, r.to.trim_end_matches('/')));
304        }
305        s
306    }
307}
308
309#[derive(Debug, Default, Deserialize)]
310#[serde(default)]
311pub struct VmSpec {
312    pub driver: String,
313    pub image: Option<String>,
314    pub port: u16,
315    pub start_command: Option<String>,
316    pub size_class: Option<String>,
317    pub disk_size_gb: Option<u32>,
318    pub working_directory: Option<String>,
319    pub env_vars: Option<BTreeMap<String, String>>,
320    pub setup_hooks: Option<Vec<String>>,
321    pub open_ports: Vec<u16>,
322    /// Directories the guests boot with, unpacked from tarballs in an artifact
323    /// store. Empty on a deployment that declares none.
324    pub mounts: Vec<MountSpec>,
325    /// A writable directory owned by the deployment, captured when a replica
326    /// retires and seeded into the next. See the server's `WorkspaceSpec`.
327    pub workspace: Option<WorkspaceSpec>,
328    pub ttl_seconds: u64,
329}
330
331/// One directory handed to every replica, unpacked from a tarball in an artifact
332/// store.
333///
334/// Read-only here, like every other spec mirror in this file: `heyctl apply`
335/// sends the file the user wrote rather than a reserialization of this struct,
336/// so a field this build has no name for still reaches the server intact.
337#[derive(Debug, Default, Clone, Deserialize)]
338#[serde(default)]
339pub struct MountSpec {
340    /// Where it appears inside the guest.
341    pub path: String,
342    pub store: String,
343    #[serde(rename = "ref")]
344    pub artifact_ref: String,
345    pub auth: Option<SecretRef>,
346    pub strip_components: Option<usize>,
347    /// Defaults to true server-side; a spec that omits it is read-only.
348    pub read_only: bool,
349    /// What `ref` resolved to on the last pull. `None` means nothing has been
350    /// pulled yet — and a deployment whose mounts have no digest has no pool
351    /// either, because the autoscaler will not boot a guest without its data.
352    pub digest: Option<String>,
353}
354
355impl MountSpec {
356    /// One line: `/data/corpus <- 127.0.0.1:8080/corpus-2026-08 (ro)`.
357    ///
358    /// The store is trimmed the same way [`ArtifactSpec::summary`] trims it, so
359    /// the two read alike when a deployment has both.
360    pub fn summary(&self) -> String {
361        let store = self
362            .store
363            .trim_end_matches('/')
364            .trim_start_matches("https://")
365            .trim_start_matches("http://");
366        format!(
367            "{} <- {store}/{} ({})",
368            self.path,
369            self.artifact_ref,
370            if self.read_only { "ro" } else { "rw" },
371        )
372    }
373}
374
375/// Where a managed deployment's image is built from: a git checkout, or a
376/// Dockerfile manifest in an artifact store. Exactly one of `repo` and `store`
377/// is set.
378#[derive(Debug, Default, Clone, Deserialize)]
379#[serde(default)]
380pub struct BuildSpec {
381    /// Empty when the recipe comes from `store`. A `String` rather than an
382    /// `Option` because `#[serde(default)]` already renders an absent field
383    /// empty, and every read of it on this side is a display.
384    pub repo: String,
385    /// Empty when the recipe comes from `repo`.
386    pub store: String,
387    #[serde(rename = "ref")]
388    pub git_ref: Option<String>,
389    pub dockerfile: Option<String>,
390    pub context: Option<String>,
391    pub image_name: Option<String>,
392    pub image_size_mb: Option<u64>,
393    pub auth: Option<SecretRef>,
394}
395
396impl BuildSpec {
397    /// One column: `github.com/acme/web@main`, or `art:8080/web-rootfs` when the
398    /// recipe comes out of a store.
399    ///
400    /// The store form deliberately reads like [`ArtifactSpec::summary`] rather
401    /// than like the git form: the two share the SOURCE column, and what a
402    /// reader scanning a fleet wants from it is *where this came from*. Which
403    /// column it appears in already says whether it is built or pulled.
404    pub fn summary(&self) -> String {
405        if !self.store.is_empty() {
406            let store = self
407                .store
408                .trim_end_matches('/')
409                .trim_start_matches("https://")
410                .trim_start_matches("http://");
411            return match &self.git_ref {
412                Some(r) => format!("{store}/{r}"),
413                // Refused by the server, so only reachable from a hand-edited
414                // state file. Rendered rather than panicked on: a listing is how
415                // somebody would find that out.
416                None => format!("{store}/(no ref)"),
417            };
418        }
419        let repo = self
420            .repo
421            .trim_end_matches(".git")
422            .trim_start_matches("https://")
423            .trim_start_matches("http://")
424            .trim_start_matches("ssh://");
425        match &self.git_ref {
426            Some(r) => format!("{repo}@{r}"),
427            None => format!("{repo}@(default branch)"),
428        }
429    }
430}
431
432/// Where a managed deployment's image is pulled from. The other image source,
433/// and mutually exclusive with [`BuildSpec`].
434#[derive(Debug, Default, Clone, Deserialize)]
435#[serde(default)]
436pub struct ArtifactSpec {
437    /// Empty (absent or `null`) means app-lb's own global artifact store.
438    #[serde(deserialize_with = "null_as_empty")]
439    pub store: String,
440    #[serde(rename = "ref")]
441    pub artifact_ref: String,
442    pub image_name: Option<String>,
443    pub grow_gb: Option<u64>,
444    pub auth: Option<SecretRef>,
445    /// Site bundles only: leading path components to drop while unpacking.
446    pub strip_components: Option<usize>,
447}
448
449impl ArtifactSpec {
450    /// One column: `10.0.0.4:8080/web-v2`. Shaped like [`BuildSpec::summary`] on
451    /// purpose — the two share the SOURCE column, and a reader scanning a fleet
452    /// should be able to tell a repo from a store at a glance without the
453    /// column changing format underneath them.
454    pub fn summary(&self) -> String {
455        if self.uses_lb_store() {
456            // app-lb's own global store: the ref already carries the namespace.
457            return self.artifact_ref.clone();
458        }
459        let store = self
460            .store
461            .trim_end_matches('/')
462            .trim_start_matches("https://")
463            .trim_start_matches("http://");
464        format!("{store}/{}", self.artifact_ref)
465    }
466
467    /// No `store`: the deployment pulls from app-lb's configured global store.
468    pub fn uses_lb_store(&self) -> bool {
469        self.store.trim().is_empty()
470    }
471}
472
473/// `null` and absent both read as an empty string.
474fn null_as_empty<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<String, D::Error> {
475    Ok(Option::<String>::deserialize(d)?.unwrap_or_default())
476}
477
478/// What one guest mount's pull did.
479#[derive(Debug, Default, Clone, Deserialize)]
480#[serde(default)]
481pub struct MountOutcome {
482    /// The guest path, which is the mount's identity within the deployment.
483    pub path: String,
484    pub store: String,
485    #[serde(rename = "ref")]
486    pub artifact_ref: String,
487    pub digest: Option<String>,
488    /// The tree on this host the guests mount.
489    pub tree: Option<String>,
490    pub files: Option<usize>,
491    /// Bytes transferred from the store. `0` with `reused` means the tree was
492    /// already on the host.
493    pub bytes: Option<u64>,
494    /// Uncompressed size of the tree — what this mount costs the host's disk.
495    pub unpacked: Option<u64>,
496    pub reused: bool,
497    /// Whether this mount's digest changed, which is what recycles the pool.
498    pub changed: bool,
499}
500
501impl MountOutcome {
502    /// The store side of the mount, trimmed the same way [`MountSpec::summary`]
503    /// trims it. Without the guest path, which the caller has already used as
504    /// the label.
505    pub fn summary(&self) -> String {
506        let store = self
507            .store
508            .trim_end_matches('/')
509            .trim_start_matches("https://")
510            .trim_start_matches("http://");
511        format!("{store}/{}", self.artifact_ref)
512    }
513}
514
515#[derive(Debug, Default, Clone, Deserialize)]
516#[serde(default)]
517pub struct SecretRef {
518    pub secret: String,
519    pub key: String,
520    pub username: Option<String>,
521    /// The namespace the reference resolves in. Stamped by app-lb from the
522    /// owning object's namespace, so it is never the client's choice — but it
523    /// is worth rendering when it is *not* the namespace being looked at, which
524    /// would otherwise read as a secret that does not exist.
525    pub namespace: Option<String>,
526}
527
528impl SecretRef {
529    pub fn render(&self) -> String {
530        format!("{}/{}", self.secret, self.key)
531    }
532
533    /// The same thing, said from inside `ns`: qualified only when the reference
534    /// resolves somewhere else.
535    pub fn render_in(&self, ns: &str) -> String {
536        match self.namespace.as_deref() {
537            Some(other) if other != ns => format!("{}/{} in namespace {other}", self.secret, self.key),
538            _ => self.render(),
539        }
540    }
541}
542
543/// Where a static deployment's backend is updated: a directory on the app-lb
544/// host, and commands to run in it.
545#[derive(Debug, Default, Clone, Deserialize)]
546#[serde(default)]
547pub struct UpdateSpec {
548    pub working_dir: String,
549    pub commands: Vec<String>,
550    pub env: Option<BTreeMap<String, String>>,
551    pub env_from: Vec<SecretEnv>,
552    pub auth: Option<SecretRef>,
553    pub timeout_secs: Option<u64>,
554    pub verify_timeout_secs: Option<u64>,
555}
556
557impl UpdateSpec {
558    /// One column: `/srv/app-obs (3 commands)`.
559    pub fn summary(&self) -> String {
560        format!(
561            "{} ({} command{})",
562            self.working_dir,
563            self.commands.len(),
564            if self.commands.len() == 1 { "" } else { "s" }
565        )
566    }
567}
568
569/// One entry in [`AuthGate::public_paths`]: a path prefix, and what app-lb
570/// requires on it in place of the sign-in gate.
571///
572/// No `#[serde(default)]`: app-lb always sends both fields, and a row with an
573/// empty `path` would silently match every request when rendered.
574#[derive(Debug, Clone, Deserialize)]
575pub struct PublicPath {
576    pub path: String,
577    /// `public` | `none` | `view` | `admin`. Only `public` admits a request
578    /// that presents no credential at all.
579    pub scope: String,
580}
581
582/// An optional sign-in gate in front of a deployment.
583#[derive(Debug, Default, Clone, Deserialize)]
584#[serde(default)]
585pub struct AuthGate {
586    /// One provider (`"google"`) or several (`["google", "app-token"]`). A
587    /// `Value` rather than a `String` because the server serializes a
588    /// single-provider gate as a bare string and a multi-provider one as an
589    /// array — modelling only the first would fail to parse the second, and
590    /// with `#[serde(default)]` that failure would look like an *absent* gate.
591    pub provider: Value,
592    /// Required for `google`, absent on a token-only gate.
593    pub client_id: Option<String>,
594    pub client_secret: Option<SecretRef>,
595    pub allowed_domains: Vec<String>,
596    pub allowed_emails: Vec<String>,
597    /// Path prefixes the *sign-in* gate does not sit in front of, and what
598    /// app-lb requires in its place.
599    ///
600    /// Read as objects rather than strings since scopes were added. app-lb
601    /// always serializes the object form, so a bare string only ever appears in
602    /// a spec somebody wrote by hand — where it means `scope: "admin"`, the
603    /// fail-closed default.
604    pub public_paths: Vec<PublicPath>,
605    /// When set, signing in at this gate mints an app-token with this scope,
606    /// which app-lb presents upstream for the life of the session. Absent on
607    /// every gate that does not — which is most of them.
608    pub session_scope: Option<String>,
609    pub base_path: String,
610    pub session_ttl_secs: u64,
611    pub cookie_name: String,
612    /// Set to share one sign-in across every deployment under a parent domain;
613    /// `None` is a per-host session.
614    pub cookie_domain: Option<String>,
615    pub redirect_url: Option<String>,
616    pub forward_identity: bool,
617    /// How a JWT is verified, when `jwt` is among the providers.
618    pub jwt: Option<JwtSpec>,
619    /// The namespace auth provider this gate inherits its identity from, if it
620    /// inherits one. Mutually exclusive with every identity field above — the
621    /// server refuses a gate that sets both — so a gate with this set looks
622    /// empty until the provider is fetched.
623    pub provider_ref: Option<String>,
624}
625
626/// How a gate verifies a JWT somebody else issued, and which ones it admits.
627///
628/// Exactly one of `secret`, `public_key` and `jwks_url` is set — the server
629/// refuses a spec with none or several.
630#[derive(Debug, Default, Clone, Deserialize)]
631#[serde(default)]
632pub struct JwtSpec {
633    pub secret: Option<SecretRef>,
634    pub public_key: Option<String>,
635    pub jwks_url: Option<String>,
636    pub algorithms: Vec<String>,
637    pub issuer: String,
638    pub audience: Option<String>,
639    /// Claims a token must satisfy. Free-form: the keys are whatever the issuer
640    /// sends, so this stays a `Value` rather than being modelled.
641    pub require: BTreeMap<String, Value>,
642    pub subject_claim: String,
643    pub email_claim: String,
644    pub name_claim: String,
645    pub leeway_secs: Option<u64>,
646    pub cookie: Option<String>,
647    /// Where a token-less *browser* is sent to acquire one. Requires `cookie`:
648    /// a navigation can only carry a credential in one.
649    pub login_url: Option<String>,
650    /// The query parameter that sign-in page reads the return URL from.
651    /// `redirect_uri` when unset.
652    pub login_redirect_param: Option<String>,
653    /// Scoped sign-in: the issuer's authorization endpoint. With `token_url`,
654    /// a token-less browser is sent through an OAuth code flow for this
655    /// deployment's namespace and gets a host-only app-lb session.
656    pub authorize_url: Option<String>,
657    /// Scoped sign-in: where app-lb redeems the code, server to server.
658    pub token_url: Option<String>,
659}
660
661impl JwtSpec {
662    /// Where the verifying key comes from, in one column.
663    pub fn key_summary(&self) -> String {
664        match (&self.secret, &self.public_key, &self.jwks_url) {
665            (Some(r), _, _) => format!("shared secret {}", r.render()),
666            (_, Some(_), _) => "an inline public key".to_string(),
667            (_, _, Some(url)) => format!("the key set at {url}"),
668            _ => "<none>".to_string(),
669        }
670    }
671
672    /// The claims a token has to satisfy, rendered the way the spec reads:
673    /// `role=admin|owner, accountId=acct_7f3c`.
674    pub fn require_summary(&self) -> String {
675        if self.require.is_empty() {
676            return "any token this issuer signed".to_string();
677        }
678        self.require
679            .iter()
680            .map(|(claim, wanted)| {
681                let rendered = match wanted {
682                    Value::Array(vs) => vs
683                        .iter()
684                        .map(render_claim_value)
685                        .collect::<Vec<_>>()
686                        .join("|"),
687                    single => render_claim_value(single),
688                };
689                format!("{claim}={rendered}")
690            })
691            .collect::<Vec<_>>()
692            .join(", ")
693    }
694}
695
696/// A claim value without JSON's quotes around a string, since every other value
697/// in this view is printed bare.
698fn render_claim_value(v: &Value) -> String {
699    match v {
700        Value::String(s) => s.clone(),
701        other => other.to_string(),
702    }
703}
704
705impl AuthGate {
706    /// Who may enter, as one line.
707    pub fn allow_summary(&self) -> String {
708        let mut parts: Vec<String> = Vec::new();
709        if self.allowed_domains.iter().any(|d| d == "*") {
710            parts.push("any Google account".into());
711        } else {
712            parts.extend(self.allowed_domains.iter().map(|d| format!("@{d}")));
713        }
714        parts.extend(self.allowed_emails.iter().cloned());
715        if parts.is_empty() {
716            "<nobody>".into()
717        } else {
718            parts.join(", ")
719        }
720    }
721
722    /// The providers this gate accepts, however the server spelled them.
723    ///
724    /// Empty in the payload means Google, which is what an `auth` block written
725    /// before app-tokens existed says by omission.
726    pub fn providers(&self) -> Vec<String> {
727        match &self.provider {
728            Value::String(s) if !s.is_empty() => vec![s.clone()],
729            Value::Array(items) => items
730                .iter()
731                .filter_map(|v| v.as_str().map(str::to_string))
732                .collect(),
733            _ => vec!["google".to_string()],
734        }
735    }
736
737    /// Whether a program can get past this gate with an app-token.
738    pub fn accepts_app_token(&self) -> bool {
739        self.providers().iter().any(|p| p == "app-token")
740    }
741
742    /// Whether a JWT from the configured issuer gets past this gate.
743    pub fn accepts_jwt(&self) -> bool {
744        self.providers().iter().any(|p| p == "jwt")
745    }
746
747    /// The URL that has to be registered with the provider, given a hostname.
748    pub fn callback_url(&self, host: &str) -> String {
749        format!(
750            "https://{host}{}/callback",
751            self.base_path.trim_end_matches('/')
752        )
753    }
754}
755
756// -- GET /auth-providers, GET /auth-providers/:namespace/:name -------------
757
758/// A declared auth provider: the identity half of a gate, named and owned by a
759/// namespace.
760///
761/// The same fields an [`AuthGate`] carries for identity — who may enter and how
762/// they are verified — with none of the route-scoped ones. A deployment
763/// inherits it with `auth.provider_ref`, and app-lb resolves it on every gated
764/// request, so editing this object reaches every deployment that names it.
765///
766/// Secrets appear as references, never values, which is what makes the object
767/// safe to print.
768#[derive(Debug, Default, Clone, Deserialize)]
769#[serde(default)]
770pub struct AuthProviderView {
771    pub name: String,
772    pub namespace: String,
773    pub description: Option<String>,
774    pub created_at: u64,
775    /// One provider or several, spelled as the server spells it — a `Value` for
776    /// the reason [`AuthGate::provider`] is one.
777    pub provider: Value,
778    pub client_id: Option<String>,
779    pub client_secret: Option<SecretRef>,
780    pub allowed_domains: Vec<String>,
781    pub allowed_emails: Vec<String>,
782    pub jwt: Option<JwtSpec>,
783    pub cookie_domain: Option<String>,
784    #[serde(flatten)]
785    pub extra: Extra,
786}
787
788impl AuthProviderView {
789    /// The providers this object admits, however the server spelled them.
790    /// Shares [`AuthGate::providers`]'s rule, including that an empty value
791    /// means Google.
792    pub fn providers(&self) -> Vec<String> {
793        match &self.provider {
794            Value::String(s) if !s.is_empty() => vec![s.clone()],
795            Value::Array(items) => items
796                .iter()
797                .filter_map(|v| v.as_str().map(str::to_string))
798                .collect(),
799            _ => vec!["google".to_string()],
800        }
801    }
802
803    /// Who this provider lets in, in one column: the Google allow-list, the
804    /// JWT `require` map, or — for `app-token` — a statement that the token's
805    /// own scope decides.
806    pub fn admits(&self) -> String {
807        let mut parts: Vec<String> = Vec::new();
808        if !self.allowed_domains.is_empty() || !self.allowed_emails.is_empty() {
809            if self.allowed_domains.iter().any(|d| d == "*") {
810                parts.push("any Google account".into());
811            } else {
812                parts.extend(self.allowed_domains.iter().map(|d| format!("@{d}")));
813            }
814            parts.extend(self.allowed_emails.iter().cloned());
815        }
816        if let Some(jwt) = &self.jwt {
817            parts.push(jwt.require_summary());
818        }
819        if parts.is_empty() && self.providers().iter().any(|p| p == "app-token") {
820            parts.push("whatever the app-token is scoped to".into());
821        }
822        if parts.is_empty() {
823            "<nobody>".into()
824        } else {
825            parts.join(", ")
826        }
827    }
828
829    /// Where the trust comes from, for the listing's one column: the issuer for
830    /// a JWT provider, the OAuth client for Google.
831    pub fn trust_summary(&self) -> String {
832        match (&self.jwt, &self.client_id) {
833            (Some(jwt), _) => jwt.issuer.clone(),
834            (None, Some(id)) => id.clone(),
835            (None, None) => "—".into(),
836        }
837    }
838}
839
840#[derive(Debug, Default, Clone, Deserialize)]
841#[serde(default)]
842pub struct SecretEnv {
843    pub secret: String,
844    pub key: String,
845    #[serde(rename = "as")]
846    pub env: Option<String>,
847}
848
849impl SecretEnv {
850    /// The variable the command sees. Mirrors the server's default of the
851    /// upper-cased key.
852    pub fn env_name(&self) -> String {
853        self.env
854            .clone()
855            .unwrap_or_else(|| self.key.to_ascii_uppercase())
856    }
857
858    pub fn render(&self) -> String {
859        format!("{}={}/{}", self.env_name(), self.secret, self.key)
860    }
861}
862
863// -- GET /secrets ----------------------------------------------------------
864
865#[derive(Debug, Default, Deserialize)]
866#[serde(default)]
867pub struct SecretSummary {
868    pub id: String,
869    pub description: Option<String>,
870    /// Key names only. app-lb has no endpoint that returns a value.
871    pub keys: Vec<String>,
872    pub updated_at: u64,
873    pub encrypted_at_rest: bool,
874    #[serde(flatten)]
875    pub extra: Extra,
876}
877
878// -- GET /workflows, GET /workflows/:id ------------------------------------
879
880/// A CI workflow object.
881///
882/// Lenient like every other read type here: unknown fields land in `extra`, and
883/// `tests/wire_contract.rs` asserts `extra` is empty against app-lb's own
884/// fixture. That assertion is the only thing that catches a field this struct
885/// stopped understanding — to a defaulting deserializer, an unknown field and an
886/// absent one are the same thing.
887#[derive(Debug, Default, Deserialize)]
888#[serde(default)]
889pub struct WorkflowView {
890    pub id: String,
891    pub repo: String,
892    /// `ref` on the wire; a Rust keyword here.
893    #[serde(rename = "ref")]
894    pub git_ref: String,
895    pub path: String,
896    pub network: String,
897    /// A reference to a stored secret, never a value.
898    pub auth: Option<Value>,
899    pub secrets_prefix: Option<String>,
900    pub enabled: bool,
901    #[serde(flatten)]
902    pub extra: Extra,
903}
904
905/// `GET /workflows` is enveloped so it can grow a cursor later without that
906/// being a breaking change.
907#[derive(Debug, Default, Deserialize)]
908#[serde(default)]
909pub struct WorkflowList {
910    pub workflows: Vec<WorkflowView>,
911    #[serde(flatten)]
912    pub extra: Extra,
913}
914
915// -- GET /jobs, POST /deployments/:id/{build,update} -----------------------
916
917/// One deploy job. Two kinds share the type, and the fields the other kind uses
918/// are simply absent — `image-build` carries `image`/`commit`, `host-update`
919/// carries `working_dir`/`commands_*`.
920#[derive(Debug, Default, Deserialize)]
921#[serde(default)]
922pub struct JobRecord {
923    pub id: String,
924    pub deployment: String,
925    /// `image-build` or `host-update`.
926    pub kind: String,
927    /// `running`, `succeeded` or `failed`.
928    pub status: String,
929    pub started_at: u64,
930    pub finished_at: Option<u64>,
931
932    // image-build
933    pub repo: String,
934    #[serde(rename = "ref")]
935    pub git_ref: Option<String>,
936    pub commit: Option<String>,
937    pub dockerfile: Option<String>,
938    pub image: Option<String>,
939    pub rolled_out: bool,
940
941    // artifact-pull
942    pub store: Option<String>,
943    /// What was asked for: a tag or a digest. Spelled `artifact` on the wire so
944    /// it does not collide with a build's `ref`.
945    #[serde(rename = "artifact")]
946    pub artifact_ref: Option<String>,
947    /// What it resolved to — the pull's answer to "which bytes are live?".
948    pub digest: Option<String>,
949    /// Bytes transferred. `0` with `reused` is a skipped fetch, not a no-op job;
950    /// `0` without it is a local store hardlinking the blob rather than copying.
951    pub bytes: Option<u64>,
952    pub reused: bool,
953    /// Set only when the pull was a *site* pull — a bundle unpacked into this
954    /// directory rather than a rootfs written to an image.
955    pub site_root: Option<String>,
956    /// Regular files unpacked, for the same kind of pull. The answer `bytes`
957    /// cannot give when the blob was hardlinked and the transfer was free.
958    pub files: Option<usize>,
959
960    // mount-pull
961    /// One entry per guest mount. A mount pull covers all of a deployment's
962    /// mounts in one job, so the single `digest`/`store` fields above stay empty
963    /// on this kind and this carries the outcome instead.
964    pub mounts: Vec<MountOutcome>,
965
966    // host-update
967    pub working_dir: Option<String>,
968    pub commands_total: Option<usize>,
969    pub commands_run: Option<usize>,
970    pub verified: Option<bool>,
971
972    pub error: Option<String>,
973    pub log: Vec<String>,
974    #[serde(flatten)]
975    pub extra: Extra,
976}
977
978impl JobRecord {
979    pub fn is_running(&self) -> bool {
980        self.status == "running"
981    }
982
983    pub fn succeeded(&self) -> bool {
984        self.status == "succeeded"
985    }
986
987    pub fn is_update(&self) -> bool {
988        self.kind == "host-update"
989    }
990
991    /// Whether this job pulled its image from an artifact store rather than
992    /// building it. The two produce the same thing — a new `vm.image` and a
993    /// recycled pool — but describe it with different fields, so every renderer
994    /// has to tell them apart.
995    pub fn is_pull(&self) -> bool {
996        self.kind == "artifact-pull"
997    }
998
999    /// Whether this job materialized the deployment's guest mounts. Distinct
1000    /// from [`Self::is_pull`] even though both fetch from a store: this one
1001    /// changes what the guests *hold*, not what they boot, and its outcome is in
1002    /// `mounts` rather than in `image`.
1003    pub fn is_mount_pull(&self) -> bool {
1004        self.kind == "mount-pull"
1005    }
1006
1007    /// The commit, short enough for a column.
1008    pub fn short_commit(&self) -> String {
1009        match &self.commit {
1010            Some(c) => c.chars().take(12).collect(),
1011            None => "—".into(),
1012        }
1013    }
1014
1015    /// The resolved digest, short enough for a column. The pull's counterpart of
1016    /// [`short_commit`](Self::short_commit).
1017    pub fn short_digest(&self) -> String {
1018        match &self.digest {
1019            Some(d) => d.chars().take(12).collect(),
1020            None => "—".into(),
1021        }
1022    }
1023
1024    /// What this job produced, as one column: the image for either image
1025    /// source, how far the commands got for an update.
1026    pub fn result_summary(&self) -> String {
1027        if self.is_update() {
1028            return match (self.commands_run, self.commands_total) {
1029                (Some(run), Some(total)) => format!("{run}/{total} commands"),
1030                _ => "—".into(),
1031            };
1032        }
1033        if self.is_mount_pull() {
1034            // What changed, not what was pulled: a mount pull that moved
1035            // nothing is the common case, and reporting "3 mounts" for it would
1036            // read as three deployments' worth of work.
1037            let changed = self.mounts.iter().filter(|m| m.changed).count();
1038            return match (self.mounts.len(), changed) {
1039                (0, _) => "—".into(),
1040                (total, 0) => format!("{total} unchanged"),
1041                (total, n) => format!("{n}/{total} updated"),
1042            };
1043        }
1044        self.image.clone().unwrap_or_else(|| "—".into())
1045    }
1046
1047    /// What it was asked to act on: a git ref for a build, a store reference for
1048    /// a pull, the directory for an update.
1049    pub fn target_summary(&self) -> String {
1050        if self.is_update() {
1051            return self.working_dir.clone().unwrap_or_else(|| "—".into());
1052        }
1053        if self.is_pull() {
1054            return self.artifact_ref.clone().unwrap_or_else(|| "—".into());
1055        }
1056        if self.is_mount_pull() {
1057            let paths: Vec<&str> = self.mounts.iter().map(|m| m.path.as_str()).take(2).collect();
1058            return match (paths.is_empty(), self.mounts.len().saturating_sub(paths.len())) {
1059                (true, _) => "—".into(),
1060                (false, 0) => paths.join(","),
1061                (false, more) => format!("{} +{more} more", paths.join(",")),
1062            };
1063        }
1064        self.git_ref.clone().unwrap_or_else(|| "(default)".into())
1065    }
1066
1067    /// How long it ran (or has been running), given the server's clock.
1068    pub fn elapsed_secs(&self, now: u64) -> u64 {
1069        self.finished_at
1070            .unwrap_or(now)
1071            .saturating_sub(self.started_at)
1072    }
1073}
1074
1075#[derive(Debug, Default, Deserialize)]
1076#[serde(default)]
1077pub struct ScalingPolicy {
1078    pub min_replicas: u32,
1079    pub max_replicas: u32,
1080    pub warm_pool: u32,
1081    pub target_concurrency: u32,
1082    pub scale_to_zero_after_secs: u64,
1083    pub cold_start_timeout_secs: u64,
1084    pub drain_timeout_secs: u64,
1085    pub boot_timeout_secs: u64,
1086    /// `destroy` or `retain` — what becomes of a VM the autoscaler retires.
1087    /// A `String` rather than an enum so a value this build has not heard of
1088    /// displays as-is instead of failing the whole read.
1089    pub idle_action: String,
1090}
1091
1092#[derive(Debug, Default, Deserialize)]
1093#[serde(default)]
1094pub struct HealthCheck {
1095    pub expected_header: Option<ExpectedHeader>,
1096    pub path: Option<String>,
1097    pub port: Option<u16>,
1098    pub timeout_secs: u64,
1099}
1100
1101#[derive(Debug, Default, Deserialize)]
1102pub struct ExpectedHeader {
1103    pub name: String,
1104    pub value: String,
1105}
1106
1107// -- GET /metrics ----------------------------------------------------------
1108
1109#[derive(Debug, Default, Deserialize)]
1110#[serde(default)]
1111pub struct MetricsResponse {
1112    pub generated_at: u64,
1113    pub uptime_secs: u64,
1114    pub host: HostUsage,
1115    pub fleet: FleetPool,
1116    pub global: DeploymentMetrics,
1117    /// Absent when the LB is not shipping logs to app-obs.
1118    pub obs: Option<ObsStats>,
1119    /// Absent when the LB has security monitoring off (`APP_LB_SIEM=0`).
1120    pub security: Option<SecuritySummary>,
1121    /// Whether app-lb can reach the VM daemon. When it cannot, the autoscaler
1122    /// abandons every tick, so nothing scales or boots and every other number
1123    /// here is frozen at whatever it was when the daemon went away.
1124    pub daemon: DaemonStatus,
1125    pub deployments: Vec<DeploymentView>,
1126    /// How many deployments matched before `limit`/`offset`, so a caller can
1127    /// page without guessing.
1128    pub matched: usize,
1129    /// How many deployments hold their own counters. Climbing past the number
1130    /// registered means retirement is not keeping up.
1131    pub tracked_deployments: usize,
1132    /// Sandboxes on the host that no deployment owns — created through the
1133    /// heyvm CLI, the cloud API or the desktop rather than by app-lb. They
1134    /// share the host with every pool; absent from an older app-lb, emptied by
1135    /// `summary=true`, and narrowed to the caller's own accounts for a
1136    /// namespace caller.
1137    #[serde(default)]
1138    pub host_sandboxes: Vec<HostSandboxView>,
1139    #[serde(flatten)]
1140    pub extra: Extra,
1141}
1142
1143/// One sandbox on the host that app-lb reports but does not manage.
1144#[derive(Debug, Default, Deserialize)]
1145#[serde(default)]
1146pub struct HostSandboxView {
1147    pub sandbox_id: String,
1148    pub name: String,
1149    /// The daemon's status string: `running`, `stopped`, `provisioning`, ….
1150    pub status: String,
1151    pub image: String,
1152    pub size_class: Option<String>,
1153    pub guest_ip: Option<String>,
1154    pub uptime_secs: u64,
1155    pub cpu_percent: Option<f64>,
1156    pub memory_bytes: Option<u64>,
1157    /// The heyo account the sandbox is billed to, when the daemon knows.
1158    pub account_id: Option<String>,
1159    /// RFC 3339, when the daemon reports it.
1160    pub created_at: Option<String>,
1161    #[serde(flatten)]
1162    pub extra: Extra,
1163}
1164
1165/// Alert counts from the detection engine, carried on `/metrics` so a status
1166/// display can show one without a second request. The alerts themselves are on
1167/// `GET /security`.
1168#[derive(Debug, Default, Deserialize)]
1169#[serde(default)]
1170pub struct SecuritySummary {
1171    /// Alerts currently held in the LB's in-memory ring.
1172    pub open: usize,
1173    /// How many of those are `high` or `critical`.
1174    pub urgent: u64,
1175    /// Observations dropped because the analysis queue was full. Non-zero means
1176    /// detection is sampling rather than complete — the same failure mode, and
1177    /// the same warning, as [`ObsStats::dropped`].
1178    pub dropped: u64,
1179    /// Whether the per-source table is full, which means the same for addresses
1180    /// as `dropped` does for events.
1181    pub clients_at_capacity: bool,
1182    /// Guard rules the data plane is enforcing, and how many requests they have
1183    /// refused. Reported beside the alert counts because "we are blocking
1184    /// traffic" belongs next to "we are seeing attacks".
1185    pub rules: usize,
1186    pub blocked: u64,
1187    #[serde(flatten)]
1188    pub extra: Extra,
1189}
1190
1191/// Log-shipping counters. Worth surfacing because the pipeline drops rather than
1192/// blocks by design, and `dropped` is the only trace a lost record leaves.
1193#[derive(Debug, Default, Deserialize)]
1194#[serde(default)]
1195pub struct ObsStats {
1196    pub queued: u64,
1197    pub dropped: u64,
1198    pub shipped: u64,
1199    pub failed: u64,
1200    pub healthy: bool,
1201    #[serde(flatten)]
1202    pub extra: Extra,
1203}
1204
1205#[derive(Debug, Default, Deserialize)]
1206#[serde(default)]
1207pub struct HostUsage {
1208    /// False until the daemon has produced a sample.
1209    pub available: bool,
1210    pub cpu_count: u64,
1211    pub cpu_percent: f64,
1212    pub memory_total_bytes: u64,
1213    pub memory_used_bytes: u64,
1214    pub sampled_at_ms: u64,
1215    #[serde(flatten)]
1216    pub extra: Extra,
1217}
1218
1219#[derive(Debug, Default, Deserialize)]
1220#[serde(default)]
1221pub struct FleetPool {
1222    pub deployments: usize,
1223    pub ready: usize,
1224    pub draining: usize,
1225    pub pending: usize,
1226    pub total_in_flight: usize,
1227    #[serde(flatten)]
1228    pub extra: Extra,
1229}
1230
1231#[derive(Debug, Default, Deserialize)]
1232#[serde(default)]
1233pub struct DeploymentView {
1234    pub id: String,
1235    /// Empty when the server omitted the default namespace.
1236    pub namespace: String,
1237    /// `"vm"`, `"static"` or `"site"`.
1238    pub kind: String,
1239    pub upstreams: Vec<String>,
1240    /// Whether at least one data-plane route points at this deployment.
1241    pub routed: bool,
1242    /// Exact hostnames this deployment is routed on — `host` rules only, since
1243    /// a `host_suffix` names no single certificate subject.
1244    pub hosts: Vec<String>,
1245    /// The same routes as URLs, with the *data plane's* scheme and port. Built
1246    /// server-side because the admin listener knows neither.
1247    pub urls: Vec<String>,
1248    /// For a site, the directory it serves. Absent for every other kind.
1249    pub site_root: Option<String>,
1250    pub site_spa: bool,
1251    /// `"build"` or `"update"` — which deploy job this deployment accepts, if
1252    /// either.
1253    pub job_kind: Option<String>,
1254    pub pool: PoolStatus,
1255    pub vms: Vec<VmView>,
1256    /// Booting VMs, oldest first — the ones holding a cold start open. A count
1257    /// alone cannot tell a 3-second boot from a guest that has been failing its
1258    /// health check for six minutes.
1259    pub pending_vms: Vec<PendingVmView>,
1260    pub metrics: DeploymentMetrics,
1261    #[serde(flatten)]
1262    pub extra: Extra,
1263}
1264
1265/// A VM created but not yet in the pool.
1266#[derive(Debug, Default, Deserialize)]
1267#[serde(default)]
1268pub struct PendingVmView {
1269    pub sandbox_id: String,
1270    /// Seconds since the daemon accepted the create call.
1271    pub age_secs: u64,
1272    /// The daemon's last reported status, absent before the first observation:
1273    /// `provisioning`, `running`, `stopped`, `paused`, `failed`, `cold-stored`
1274    /// or `unknown`.
1275    pub status: Option<String>,
1276    #[serde(flatten)]
1277    pub extra: Extra,
1278}
1279
1280#[derive(Debug, Default, Deserialize)]
1281#[serde(default)]
1282pub struct PoolStatus {
1283    pub desired_replicas: u32,
1284    pub ready: usize,
1285    pub draining: usize,
1286    pub pending: usize,
1287    pub total_in_flight: usize,
1288    pub target_concurrency: u32,
1289    pub min_replicas: u32,
1290    pub max_replicas: u32,
1291    pub warm_pool: u32,
1292    /// `None` when there is no available capacity to divide by — rendered as
1293    /// "—", never as a fake 0%.
1294    pub utilization: Option<f64>,
1295    pub cpu_percent: Option<f64>,
1296    pub memory_bytes: Option<u64>,
1297    /// How long a booting VM gets before the autoscaler kills it; `0` waits
1298    /// indefinitely.
1299    pub boot_timeout_secs: u64,
1300    /// How long a request waits on a cold start. A pending VM older than this
1301    /// has already cost somebody a 503.
1302    pub cold_start_timeout_secs: u64,
1303    /// Failed boots in a row since the last VM that passed its health check.
1304    pub boot_failures: u64,
1305    /// Seconds until the autoscaler may create a VM again while the
1306    /// boot-failure backoff holds it off; `None` when it may create one now.
1307    /// Any spec write clears it.
1308    pub boot_backoff_secs: Option<u64>,
1309    #[serde(flatten)]
1310    pub extra: Extra,
1311}
1312
1313#[derive(Debug, Default, Deserialize)]
1314#[serde(default)]
1315pub struct VmView {
1316    pub sandbox_id: String,
1317    pub addr: String,
1318    pub in_flight: usize,
1319    pub healthy: bool,
1320    pub draining: bool,
1321    pub uptime_secs: u64,
1322    pub cpu_percent: Option<f64>,
1323    pub memory_bytes: Option<u64>,
1324    #[serde(flatten)]
1325    pub extra: Extra,
1326}
1327
1328impl VmView {
1329    pub fn status(&self) -> &'static str {
1330        if self.draining {
1331            "Draining"
1332        } else if self.healthy {
1333            "Ready"
1334        } else {
1335            "NotReady"
1336        }
1337    }
1338}
1339
1340#[derive(Debug, Default, Deserialize)]
1341#[serde(default)]
1342pub struct DeploymentMetrics {
1343    pub requests: StatusCounts,
1344    pub latency_ms: Histogram,
1345    pub cold_start_s: Histogram,
1346    pub autoscale: AutoscaleCounts,
1347    #[serde(flatten)]
1348    pub extra: Extra,
1349}
1350
1351#[derive(Debug, Default, Deserialize)]
1352#[serde(default)]
1353pub struct StatusCounts {
1354    pub total: u64,
1355    pub c2xx: u64,
1356    pub c3xx: u64,
1357    pub c4xx: u64,
1358    pub c5xx: u64,
1359    pub errors: u64,
1360    #[serde(flatten)]
1361    pub extra: Extra,
1362}
1363
1364#[derive(Debug, Default, Deserialize)]
1365#[serde(default)]
1366pub struct Histogram {
1367    pub count: u64,
1368    pub sum: u64,
1369    pub mean: f64,
1370    pub p50: f64,
1371    pub p90: f64,
1372    pub p99: f64,
1373    #[serde(flatten)]
1374    pub extra: Extra,
1375}
1376
1377#[derive(Debug, Default, Deserialize)]
1378#[serde(default)]
1379pub struct AutoscaleCounts {
1380    pub vms_created: u64,
1381    pub vms_drained: u64,
1382    pub vms_reaped: u64,
1383    pub scale_up_events: u64,
1384    pub scale_down_events: u64,
1385    pub cold_start_waits: u64,
1386    pub cold_start_hits: u64,
1387    pub cold_start_timeouts: u64,
1388    /// VMs killed for never passing their health check inside the boot timeout.
1389    /// Was absent from this struct while app-lb had been sending it — the exact
1390    /// silent drift `extra` exists to catch, and the reason `describe` could not
1391    /// tell "the guest never became healthy" from "no VM was ever created".
1392    pub boot_timeouts: u64,
1393    /// Creates the daemon refused outright. The distinguishing counter: with
1394    /// `vms_created` and `boot_timeouts` both zero, a non-zero value here means
1395    /// no VM ever existed, so the guest image is not the thing to debug.
1396    pub create_failures: u64,
1397    /// What the daemon said the last time it refused.
1398    pub last_create_error: Option<String>,
1399    #[serde(flatten)]
1400    pub extra: Extra,
1401}
1402
1403#[derive(Debug, Default, Deserialize)]
1404#[serde(default)]
1405pub struct DaemonStatus {
1406    pub reachable: bool,
1407    pub last_error: Option<String>,
1408    #[serde(flatten)]
1409    pub extra: Extra,
1410}
1411
1412// -- GET /certs ------------------------------------------------------------
1413
1414#[derive(Debug, Default, Deserialize)]
1415#[serde(default)]
1416pub struct CertStatus {
1417    pub host: String,
1418    pub not_after: String,
1419    pub issuer: String,
1420    pub needs_renewal: bool,
1421    #[serde(flatten)]
1422    pub extra: Extra,
1423}
1424
1425// -- GET /disks ------------------------------------------------------------
1426
1427/// What the daemon thinks of the sandbox a disk belongs to.
1428///
1429/// `Unknown` is not a parse failure — it is what app-lb reports when it could
1430/// not reach the daemon, and such a disk is never reclaimed. A state this build
1431/// does not recognise degrades to `Unknown` for the same reason: an unfamiliar
1432/// word must not blank the whole listing.
1433#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)]
1434#[serde(rename_all = "lowercase")]
1435pub enum DiskState {
1436    Running,
1437    Stopped,
1438    Orphan,
1439    #[default]
1440    #[serde(other)]
1441    Unknown,
1442}
1443
1444impl DiskState {
1445    pub fn label(self) -> &'static str {
1446        match self {
1447            Self::Running => "running",
1448            Self::Stopped => "stopped",
1449            Self::Orphan => "orphan",
1450            Self::Unknown => "unknown",
1451        }
1452    }
1453}
1454
1455/// One file on the host and what it is for.
1456#[derive(Debug, Default, Deserialize)]
1457#[serde(default)]
1458pub struct DiskPart {
1459    pub kind: String,
1460    pub path: String,
1461    /// Blocks actually allocated. The number that matters: a `data.ext4` is
1462    /// created sparse at its full nominal size, so `apparent_bytes` routinely
1463    /// reads tens of GiB for a fraction of that in real occupancy.
1464    pub bytes: u64,
1465    pub apparent_bytes: u64,
1466    pub modified_at: u64,
1467    #[serde(flatten)]
1468    pub extra: Extra,
1469}
1470
1471#[derive(Debug, Default, Deserialize)]
1472#[serde(default)]
1473pub struct DiskArchiveRecord {
1474    pub uri: String,
1475    pub at: u64,
1476    pub bytes: u64,
1477    #[serde(flatten)]
1478    pub extra: Extra,
1479}
1480
1481#[derive(Debug, Default, Deserialize)]
1482#[serde(default)]
1483pub struct DiskArchiveView {
1484    pub id: String,
1485    pub sandbox_id: String,
1486    pub uri: String,
1487    pub started_at: u64,
1488    pub finished_at: Option<u64>,
1489    pub status: String,
1490    pub bytes: u64,
1491    pub expected_bytes: u64,
1492    pub error: Option<String>,
1493    pub purged: bool,
1494    #[serde(flatten)]
1495    pub extra: Extra,
1496}
1497
1498/// Everything on the host belonging to one sandbox.
1499#[derive(Debug, Default, Deserialize)]
1500#[serde(default)]
1501pub struct DiskInfo {
1502    pub sandbox_id: String,
1503    /// The daemon's name for it, when the daemon still has a record.
1504    pub name: Option<String>,
1505    /// The deployment that owns it, from an `applb-<deployment>-<nonce>` name.
1506    /// `None` for a sandbox app-lb did not create, or one whose name is gone.
1507    pub deployment: Option<String>,
1508    pub state: DiskState,
1509    /// A live deployment lists this sandbox as one it intends to resume. A
1510    /// claimed disk is never reclaimed, at any age.
1511    pub claimed: bool,
1512    /// Set by an operator: never reclaim this, whatever its age.
1513    pub retain: bool,
1514    pub note: Option<String>,
1515    pub bytes: u64,
1516    pub apparent_bytes: u64,
1517    pub modified_at: u64,
1518    /// When the sweep would reclaim this, or `None` if it never would.
1519    pub expires_at: Option<u64>,
1520    /// Why it will not be reclaimed, in a phrase meant to be shown verbatim.
1521    pub held_by: Option<String>,
1522    pub archived: Option<DiskArchiveRecord>,
1523    pub parts: Vec<DiskPart>,
1524    /// The directories a purge would remove.
1525    pub roots: Vec<String>,
1526    #[serde(flatten)]
1527    pub extra: Extra,
1528}
1529
1530#[derive(Debug, Default, Deserialize)]
1531#[serde(default)]
1532pub struct DiskTotals {
1533    pub disks: usize,
1534    pub bytes: u64,
1535    pub apparent_bytes: u64,
1536    pub running: usize,
1537    pub stopped: usize,
1538    pub orphan: usize,
1539    pub retained: usize,
1540    /// Disks the sweep would reclaim right now, and what that would free.
1541    pub expiring_now: usize,
1542    pub reclaimable_bytes: u64,
1543    #[serde(flatten)]
1544    pub extra: Extra,
1545}
1546
1547/// `GET /disks` — the host's per-sandbox disk inventory.
1548#[derive(Debug, Default, Deserialize)]
1549#[serde(default)]
1550pub struct DiskInventory {
1551    /// Whether both daemon listings succeeded. When false nothing is classified
1552    /// as an orphan and the sweep declines to run — so a listing that says
1553    /// `complete: false` must not be read as "these disks are residue".
1554    pub complete: bool,
1555    pub incomplete_reason: Option<String>,
1556    pub data_dir: String,
1557    pub tmp_dir: String,
1558    /// `0` when expiry is disabled.
1559    pub ttl_secs: u64,
1560    pub sweep_secs: u64,
1561    pub archive_enabled: bool,
1562    pub archive_on_expire: bool,
1563    pub archive_target: Option<String>,
1564    /// Free and total bytes on the filesystem holding the guest disks, when it
1565    /// could be measured. `None` means unknown, never "full".
1566    pub free_bytes: Option<u64>,
1567    pub filesystem_bytes: Option<u64>,
1568    /// How long a disk with no daemon record survives, against `ttl_secs` for
1569    /// everything else. Far shorter: an orphan is the disk of a VM that failed
1570    /// to create or died without a trace, and nothing will ever resume it.
1571    pub orphan_ttl_secs: u64,
1572    pub totals: DiskTotals,
1573    pub disks: Vec<DiskInfo>,
1574    pub archives: Vec<DiskArchiveView>,
1575    #[serde(flatten)]
1576    pub extra: Extra,
1577}
1578
1579/// `GET /api/plugins` and `/api/plugins/:id` — a built-in plugin, its stored
1580/// record and its live status.
1581#[derive(Debug, Default, Clone, Deserialize)]
1582#[serde(default)]
1583pub struct PluginView {
1584    pub id: String,
1585    pub name: String,
1586    pub description: String,
1587    /// JSON Schema for `config`. Advisory; app-lb validates on write.
1588    pub config_schema: Value,
1589    /// Whether namespaces install this plugin for themselves (`heyctl plugins
1590    /// install`), on top of the fleet-wide switch.
1591    pub per_namespace: bool,
1592    /// The namespaces it is installed in. Empty for a fleet-only plugin.
1593    pub installed_in: Vec<String>,
1594    /// Where its dashboard sits on a namespace's surface (`ui`), for a
1595    /// plugin that has one; app-lb frames it at
1596    /// `/namespaces/<ns>/plugin-console/<id>`.
1597    pub dashboard: Option<String>,
1598    pub enabled: bool,
1599    /// Kept while disabled, so re-enabling does not lose it.
1600    pub config: Value,
1601    pub updated_at: u64,
1602    /// Why the last apply failed. A plugin can be enabled *and* failing.
1603    pub last_error: Option<String>,
1604    /// Whatever the plugin reports about itself; shape is per plugin.
1605    pub status: Value,
1606    #[serde(flatten)]
1607    pub extra: Extra,
1608}
1609
1610#[cfg(test)]
1611mod tests {
1612    use super::*;
1613
1614    #[test]
1615    fn a_deployment_parses_from_a_partial_body() {
1616        // Everything defaults, so a server that grows a field — or drops one
1617        // this build knows about — still renders.
1618        let d: DeploymentStatus = serde_json::from_str(r#"{"spec":{"id":"web"},"ready":2}"#).unwrap();
1619        assert_eq!(d.spec.id, "web");
1620        assert_eq!(d.ready, 2);
1621        assert_eq!(d.desired_replicas, 0);
1622    }
1623
1624    #[test]
1625    fn routes_render_with_their_matching_semantics() {
1626        let exact = RouteRule {
1627            host: Some("secrets.local".into()),
1628            ..Default::default()
1629        };
1630        assert_eq!(exact.render(), "secrets.local");
1631
1632        let wild = RouteRule {
1633            host_suffix: Some(".apps.example.com".into()),
1634            path_prefix: Some("/api".into()),
1635            ..Default::default()
1636        };
1637        assert_eq!(wild.render(), "*.apps.example.com/api");
1638
1639        let path_only = RouteRule {
1640            path_prefix: Some("/legacy".into()),
1641            ..Default::default()
1642        };
1643        assert_eq!(path_only.render(), "*/legacy");
1644
1645        assert_eq!(RouteRule::default().render(), "<empty>");
1646    }
1647
1648    #[test]
1649    fn many_routes_collapse_to_a_summary() {
1650        let spec = DeploymentSpec {
1651            routes: (0..5)
1652                .map(|i| RouteRule {
1653                    host: Some(format!("h{i}.local")),
1654                    ..Default::default()
1655                })
1656                .collect(),
1657            ..Default::default()
1658        };
1659        assert_eq!(spec.routes_summary(), "h0.local,h1.local,h2.local +2 more");
1660    }
1661
1662    #[test]
1663    fn a_build_source_renders_as_one_column() {
1664        let b = BuildSpec {
1665            repo: "https://github.com/acme/web.git".into(),
1666            git_ref: Some("main".into()),
1667            ..Default::default()
1668        };
1669        assert_eq!(b.summary(), "github.com/acme/web@main");
1670
1671        let default_branch = BuildSpec {
1672            repo: "git@github.com:acme/web.git".into(),
1673            ..Default::default()
1674        };
1675        assert_eq!(
1676            default_branch.summary(),
1677            "git@github.com:acme/web@(default branch)"
1678        );
1679
1680        // A recipe out of a store reads like the store column, not like a repo:
1681        // both share SOURCE, and which column it lands in already says whether
1682        // this is built or pulled.
1683        let from_store = BuildSpec {
1684            store: "http://art.internal:8080".into(),
1685            git_ref: Some("web-rootfs".into()),
1686            ..Default::default()
1687        };
1688        assert_eq!(from_store.summary(), "art.internal:8080/web-rootfs");
1689
1690        // `store` wins over an empty `repo` without either being an Option: an
1691        // absent field is empty under #[serde(default)], and that is the whole
1692        // signal.
1693        let parsed: BuildSpec =
1694            serde_json::from_str(r#"{"store":"/srv/artifacts","ref":"web"}"#).unwrap();
1695        assert_eq!(parsed.summary(), "/srv/artifacts/web");
1696    }
1697
1698    #[test]
1699    fn a_job_record_parses_from_a_partial_body() {
1700        let r: JobRecord =
1701            serde_json::from_str(r#"{"id":"job-1","status":"running","started_at":100}"#).unwrap();
1702        assert!(r.is_running());
1703        assert!(!r.succeeded());
1704        assert_eq!(r.short_commit(), "—");
1705        assert_eq!(r.elapsed_secs(130), 30, "a running job measures against now");
1706
1707        let done: JobRecord = serde_json::from_str(
1708            r#"{"id":"job-1","kind":"image-build","status":"succeeded","started_at":100,
1709                "finished_at":160,"commit":"0123456789abcdef","ref":"main","image":"web-0123"}"#,
1710        )
1711        .unwrap();
1712        assert!(done.succeeded());
1713        assert_eq!(done.short_commit(), "0123456789ab");
1714        assert_eq!(done.elapsed_secs(999), 60, "a finished one measures its own span");
1715        assert_eq!(done.target_summary(), "main");
1716        assert_eq!(done.result_summary(), "web-0123");
1717    }
1718
1719    /// The two kinds share the type, so the columns have to mean the right thing
1720    /// for each: a host update has no image and no commit.
1721    #[test]
1722    fn an_update_record_summarizes_its_own_fields() {
1723        let update: JobRecord = serde_json::from_str(
1724            r#"{"id":"job-2","kind":"host-update","status":"failed","started_at":100,
1725                "working_dir":"/srv/app-obs","commands_total":3,"commands_run":1,
1726                "verified":false}"#,
1727        )
1728        .unwrap();
1729        assert!(update.is_update());
1730        assert_eq!(update.target_summary(), "/srv/app-obs");
1731        assert_eq!(update.result_summary(), "1/3 commands");
1732        assert_eq!(update.verified, Some(false));
1733    }
1734
1735    #[test]
1736    fn an_artifact_source_renders_as_one_column_shaped_like_a_build_one() {
1737        let a = ArtifactSpec {
1738            store: "http://10.0.0.4:8080/".into(),
1739            artifact_ref: "web-v2".into(),
1740            ..Default::default()
1741        };
1742        assert_eq!(a.summary(), "10.0.0.4:8080/web-v2");
1743
1744        // A store root keeps its leading slash — it is a path, and stripping it
1745        // would make an absolute one look relative in the SOURCE column.
1746        let local = ArtifactSpec {
1747            store: "/srv/artifacts".into(),
1748            artifact_ref: "debian-hermes".into(),
1749            ..Default::default()
1750        };
1751        assert_eq!(local.summary(), "/srv/artifacts/debian-hermes");
1752
1753        // No store: app-lb's global one, and the ref alone says where.
1754        for json in [
1755            r#"{"ref":"acme/web:v2"}"#,
1756            r#"{"store":null,"ref":"acme/web:v2"}"#,
1757            r#"{"store":"","ref":"acme/web:v2"}"#,
1758        ] {
1759            let a: ArtifactSpec = serde_json::from_str(json).unwrap();
1760            assert!(a.uses_lb_store(), "{json}");
1761            assert_eq!(a.summary(), "acme/web:v2");
1762        }
1763    }
1764
1765    #[test]
1766    fn a_pull_record_summarizes_the_store_side_fields_not_the_git_ones() {
1767        let pull: JobRecord = serde_json::from_str(
1768            r#"{"id":"job-3","kind":"artifact-pull","status":"succeeded","started_at":100,
1769                "store":"http://127.0.0.1:8080","artifact":"debian-hermes",
1770                "digest":"c74abee2ce8409f1aaaa","image":"web-c74abee2ce84",
1771                "bytes":609222656,"rolled_out":true}"#,
1772        )
1773        .unwrap();
1774        assert!(pull.is_pull());
1775        assert!(!pull.is_update());
1776        // The reference asked for, not a git ref it does not have.
1777        assert_eq!(pull.target_summary(), "debian-hermes");
1778        assert_eq!(pull.result_summary(), "web-c74abee2ce84");
1779        assert_eq!(pull.short_digest(), "c74abee2ce84");
1780        assert_eq!(pull.short_commit(), "—", "a pull has no commit");
1781        assert!(!pull.reused);
1782    }
1783
1784    #[test]
1785    fn a_reused_image_is_distinguishable_from_a_pull_that_never_ran() {
1786        let reused: JobRecord = serde_json::from_str(
1787            r#"{"id":"job-4","kind":"artifact-pull","status":"succeeded","started_at":1,
1788                "bytes":0,"reused":true}"#,
1789        )
1790        .unwrap();
1791        assert!(reused.reused);
1792        assert_eq!(reused.bytes, Some(0));
1793
1794        // A record with neither is one that failed before it got that far.
1795        let failed: JobRecord = serde_json::from_str(
1796            r#"{"id":"job-5","kind":"artifact-pull","status":"failed","started_at":1}"#,
1797        )
1798        .unwrap();
1799        assert!(!failed.reused);
1800        assert_eq!(failed.bytes, None);
1801    }
1802
1803    #[test]
1804    fn an_update_source_renders_its_directory_and_command_count() {
1805        let u = UpdateSpec {
1806            working_dir: "/srv/app-obs".into(),
1807            commands: vec!["git pull".into(), "cargo build --release".into()],
1808            ..Default::default()
1809        };
1810        assert_eq!(u.summary(), "/srv/app-obs (2 commands)");
1811
1812        let one = UpdateSpec {
1813            working_dir: "/srv/x".into(),
1814            commands: vec!["make deploy".into()],
1815            ..Default::default()
1816        };
1817        assert_eq!(one.summary(), "/srv/x (1 command)");
1818    }
1819
1820    #[test]
1821    fn a_gate_says_who_may_enter_and_where_the_provider_redirects() {
1822        let g = AuthGate {
1823            allowed_domains: vec!["example.com".into()],
1824            allowed_emails: vec!["contractor@gmail.com".into()],
1825            base_path: "/__applb/auth".into(),
1826            ..Default::default()
1827        };
1828        assert_eq!(g.allow_summary(), "@example.com, contractor@gmail.com");
1829        assert_eq!(
1830            g.callback_url("app.example.com"),
1831            "https://app.example.com/__applb/auth/callback"
1832        );
1833
1834        // The escape hatch reads as what it is, not as a literal `@*`.
1835        let any = AuthGate {
1836            allowed_domains: vec!["*".into()],
1837            ..Default::default()
1838        };
1839        assert_eq!(any.allow_summary(), "any Google account");
1840
1841        // A server a version ahead could send a gate this build understands
1842        // nothing about; it must still render rather than fail to parse.
1843        let unknown: AuthGate = serde_json::from_str(r#"{"provider":"okta","client_id":"x"}"#).unwrap();
1844        assert_eq!(unknown.provider, "okta");
1845        assert_eq!(unknown.allow_summary(), "<nobody>");
1846    }
1847
1848    #[test]
1849    fn a_secret_env_defaults_to_the_upper_cased_key() {
1850        let e = SecretEnv {
1851            secret: "obs".into(),
1852            key: "ingest_token".into(),
1853            env: None,
1854        };
1855        assert_eq!(e.env_name(), "INGEST_TOKEN");
1856        assert_eq!(e.render(), "INGEST_TOKEN=obs/ingest_token");
1857
1858        let renamed = SecretEnv {
1859            env: Some("APP_OBS_INGEST_TOKEN".into()),
1860            ..e
1861        };
1862        assert_eq!(renamed.render(), "APP_OBS_INGEST_TOKEN=obs/ingest_token");
1863    }
1864
1865    #[test]
1866    fn vm_status_puts_draining_ahead_of_health() {
1867        let vm = VmStatus {
1868            healthy: true,
1869            draining: true,
1870            ..Default::default()
1871        };
1872        assert_eq!(vm.status(), "Draining");
1873    }
1874}
1875
1876// -- POST /deployments/:id/exec --------------------------------------------
1877
1878/// What a command did. A non-zero `exit_code` is a successful *request* — the
1879/// command ran and failed, which is not the same as being unable to run it.
1880#[derive(Debug, Default, Deserialize)]
1881#[serde(default)]
1882pub struct ExecOutput {
1883    /// Which VM ran it. Worth having even for a single-VM sandbox: after a
1884    /// resume or a rebuild it is a different sandbox than last time.
1885    pub sandbox_id: String,
1886    pub exit_code: i32,
1887    pub stdout: String,
1888    pub stderr: String,
1889    /// stdout and stderr interleaved as the guest wrote them. Not
1890    /// `stdout + stderr` — the only faithful rendering of interleaved output.
1891    pub output: String,
1892    #[serde(flatten)]
1893    pub extra: Extra,
1894}
1895
1896impl ExecOutput {
1897    /// Whether the command itself succeeded.
1898    pub fn ok(&self) -> bool {
1899        self.exit_code == 0
1900    }
1901}
1902
1903// -- DELETE /deployments/:id/vms/:sandbox_id -------------------------------
1904
1905#[derive(Debug, Default, Deserialize)]
1906#[serde(default)]
1907pub struct EvictOutcome {
1908    pub sandbox_id: String,
1909    /// `"killed"` (immediate) or `"draining"` (still serving what it has).
1910    pub outcome: String,
1911    #[serde(flatten)]
1912    pub extra: Extra,
1913}
1914
1915impl EvictOutcome {
1916    pub fn is_draining(&self) -> bool {
1917        self.outcome == "draining"
1918    }
1919}
1920
1921// -- PUT/DELETE /deployments/:id/upstreams/:upstream/drain ----------------
1922
1923#[derive(Debug, Default, Deserialize, serde::Serialize)]
1924#[serde(default)]
1925pub struct UpstreamTrafficStatus {
1926    pub deployment_id: String,
1927    pub upstream: String,
1928    /// `accepting`, `draining`, or `drained`.
1929    pub state: String,
1930    /// Probe state, independent from the administrative traffic state above.
1931    pub healthy: bool,
1932    pub in_flight: usize,
1933    pub reason: Option<String>,
1934    pub started_at: Option<u64>,
1935    #[serde(flatten)]
1936    pub extra: Extra,
1937}
1938
1939impl UpstreamTrafficStatus {
1940    pub fn is_drained(&self) -> bool {
1941        self.state == "drained" && self.in_flight == 0
1942    }
1943
1944    pub fn is_accepting(&self) -> bool {
1945        self.state == "accepting"
1946    }
1947}
1948
1949// -- app-tokens -------------------------------------------------------------
1950
1951/// What a token may do on the admin API.
1952#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, serde::Serialize)]
1953#[serde(rename_all = "lowercase")]
1954pub enum AdminScope {
1955    /// Nothing. Still usable against a deployment's own gate, which is the
1956    /// point of a token handed to an application.
1957    #[default]
1958    None,
1959    /// `/metrics` and `/dashboard`.
1960    View,
1961    /// Everything, within the token's `deployments` scope.
1962    Admin,
1963}
1964
1965impl std::fmt::Display for AdminScope {
1966    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1967        f.write_str(match self {
1968            Self::None => "none",
1969            Self::View => "view",
1970            Self::Admin => "admin",
1971        })
1972    }
1973}
1974
1975#[derive(Debug, Default, Clone, Deserialize)]
1976#[serde(default)]
1977pub struct TokenSummary {
1978    pub id: String,
1979    pub name: String,
1980    pub admin: AdminScope,
1981    /// The namespace this token is confined to, if any. Inside it, an empty
1982    /// `deployments` list means every deployment there; outside it the token
1983    /// reaches nothing.
1984    pub namespace: Option<String>,
1985    /// Deployment ids, or `["*"]` for all of them.
1986    pub deployments: Vec<String>,
1987    pub created_at: u64,
1988    pub expires_at: Option<u64>,
1989    /// `None` also means "not used since the store was last written" — the
1990    /// stamp is flushed opportunistically, not per request, so a busy token can
1991    /// still read as unused.
1992    pub last_used_at: Option<u64>,
1993    /// Minted at a control plane and valid on every server that mirrors it.
1994    pub fleet: bool,
1995    /// Set when this server holds the token only as a mirror: the control
1996    /// plane it came from, which is where it is changed or revoked.
1997    pub mirrored_from: Option<String>,
1998    #[serde(flatten)]
1999    pub extra: Extra,
2000}
2001
2002impl TokenSummary {
2003    /// Whether this token's scope covers the whole fleet. A namespace token
2004    /// never does — `["*"]` inside a namespace means everything *there*.
2005    pub fn covers_fleet(&self) -> bool {
2006        self.namespace.is_none() && self.deployments.iter().any(|d| d == "*")
2007    }
2008
2009    pub fn allows(&self, deployment: &str) -> bool {
2010        self.deployments.iter().any(|d| d == "*" || d == deployment)
2011    }
2012}
2013
2014// -- namespaces -------------------------------------------------------------
2015
2016/// One namespace, from `GET /namespaces`.
2017///
2018/// Distinct from [`FeedIndexEntry`], which answers a narrower question: that one
2019/// lists namespaces with *feed events*, this one lists namespaces with
2020/// deployments the caller can see. A namespace appears here the moment
2021/// something is registered in it and disappears when the last thing leaves —
2022/// there is no namespace object to create or delete.
2023#[derive(Debug, Default, Clone, Deserialize)]
2024#[serde(default)]
2025pub struct NamespaceEntry {
2026    pub namespace: String,
2027    /// Deployments in it that *this credential* may see. Narrowed server-side,
2028    /// so a scoped token sees its own arithmetic rather than the fleet's.
2029    pub deployments: u64,
2030    /// Whether a namespace *object* exists, as opposed to the name being one a
2031    /// deployment happens to mention. Both scope identically; the difference is
2032    /// whether there is anything to delete or describe.
2033    pub declared: bool,
2034    pub description: Option<String>,
2035    /// Present only when `declared`.
2036    pub created_at: Option<u64>,
2037    #[serde(flatten)]
2038    pub extra: Extra,
2039}
2040
2041// -- the event feed ---------------------------------------------------------
2042
2043/// One namespace that has feed events, from `GET /feeds`.
2044#[derive(Debug, Default, Clone, Deserialize)]
2045#[serde(default)]
2046pub struct FeedIndexEntry {
2047    pub namespace: String,
2048    pub events: u64,
2049    #[serde(flatten)]
2050    pub extra: Extra,
2051}
2052
2053/// One event from a namespace's feed, from `GET /feeds/:ns?format=json`.
2054///
2055/// The same entries the RSS document carries; `id` is the RSS `<guid>`.
2056#[derive(Debug, Default, Clone, Deserialize)]
2057#[serde(default)]
2058pub struct FeedEvent {
2059    pub id: u64,
2060    /// When the event first happened.
2061    pub ts: u64,
2062    /// When it last happened — repeats of the same issue fold into one entry.
2063    pub last_ts: u64,
2064    pub count: u64,
2065    pub namespace: String,
2066    pub deployment: String,
2067    /// `deployed`, `updated`, `removed` or `issue`.
2068    pub kind: String,
2069    pub title: String,
2070    pub detail: String,
2071    #[serde(flatten)]
2072    pub extra: Extra,
2073}
2074
2075/// The reply to a mint. **`token` is the only time the secret is ever
2076/// returned** — app-lb stores only its hash, and there is no endpoint that
2077/// reads it back. Store it here or mint another one.
2078#[derive(Debug, Default, Clone, Deserialize)]
2079pub struct MintedToken {
2080    #[serde(flatten)]
2081    pub summary: TokenSummary,
2082    pub token: String,
2083}
2084
2085// -- GET /whoami ------------------------------------------------------------
2086
2087/// What the server makes of the credential presented, from `GET /whoami`.
2088///
2089/// The one admin route with no tier requirement, so even a token minted with
2090/// `admin: none` can ask it why everything else refuses.
2091#[derive(Debug, Default, Clone, Deserialize)]
2092#[serde(default)]
2093pub struct WhoAmI {
2094    /// `ungated`, `operator`, `app-token` or `federated`.
2095    pub caller: String,
2096    /// `none`, `view`, `admin` — or `unchecked` on an ungated listener. For a
2097    /// federated caller, the strongest tier it holds anywhere.
2098    pub admin_scope: String,
2099    /// Whether this credential may use the fleet-wide routes.
2100    pub fleet: bool,
2101    /// Whether it is behind a namespace wall.
2102    pub confined: bool,
2103    pub may: WhoAmIMay,
2104    /// An app-token's id and name.
2105    pub token: Option<WhoAmIToken>,
2106    /// An app-token's namespace, when it is confined to one.
2107    pub namespace: Option<String>,
2108    /// An app-token's deployment list, verbatim: `["*"]` is every deployment,
2109    /// and empty on a namespace token is everything in that namespace.
2110    pub deployments: Option<Vec<String>>,
2111    pub expires_at: Option<u64>,
2112    pub expires_in_secs: Option<u64>,
2113    /// A federated caller's identity, as the auth service reported it.
2114    pub subject: Option<Value>,
2115    /// A federated caller's namespaces and the tier it holds in each.
2116    pub namespaces: Option<BTreeMap<String, String>>,
2117    pub detail: Option<String>,
2118    pub note: Option<String>,
2119    #[serde(flatten)]
2120    pub extra: Extra,
2121}
2122
2123impl WhoAmI {
2124    /// The single namespace this credential is confined to, if there is
2125    /// exactly one — the namespace a command may assume when none is named.
2126    pub fn sole_namespace(&self) -> Option<&str> {
2127        if !self.confined {
2128            return None;
2129        }
2130        if let Some(ns) = self.namespace.as_deref() {
2131            return Some(ns);
2132        }
2133        match &self.namespaces {
2134            Some(map) if map.len() == 1 => map.keys().next().map(String::as_str),
2135            _ => None,
2136        }
2137    }
2138}
2139
2140#[derive(Debug, Default, Clone, Deserialize)]
2141#[serde(default)]
2142pub struct WhoAmIMay {
2143    pub read_view_routes: bool,
2144    pub use_admin_routes: bool,
2145    #[serde(flatten)]
2146    pub extra: Extra,
2147}
2148
2149#[derive(Debug, Default, Clone, Deserialize)]
2150#[serde(default)]
2151pub struct WhoAmIToken {
2152    pub id: String,
2153    pub name: String,
2154    #[serde(flatten)]
2155    pub extra: Extra,
2156}
2157
2158// -- rollouts ---------------------------------------------------------------
2159
2160/// A replace-by-rollout operation, from `POST /deployments/:id/rollouts` and
2161/// `GET /deployments/:id/rollouts/:operation`.
2162#[derive(Debug, Default, Clone, Deserialize)]
2163#[serde(default)]
2164pub struct RolloutOperation {
2165    pub operation_id: String,
2166    pub deployment: String,
2167    /// The deployment's `rollout_revision` the operation was admitted against.
2168    pub source_revision: String,
2169    pub target_spec_sha256: String,
2170    /// `running`, `succeeded`, `failed` or `reconciliation_required`.
2171    pub status: String,
2172    pub phase: String,
2173    pub readiness_verified: bool,
2174    pub previous_stopped: bool,
2175    pub error: Option<String>,
2176    pub preparation_stage: Option<String>,
2177    /// Present once a failed rollout's candidates have been reclaimed.
2178    pub failure_settlement: Option<Value>,
2179    #[serde(flatten)]
2180    pub extra: Extra,
2181}
2182
2183impl RolloutOperation {
2184    /// Whether the operation has stopped moving, successfully or not.
2185    pub fn is_finished(&self) -> bool {
2186        !matches!(self.status.as_str(), "running" | "reconciliation_required")
2187    }
2188}
2189
2190// -- discovery --------------------------------------------------------------
2191
2192/// What a gateway publishes about a deployment for discovery, from
2193/// `GET /deployments/:id/discovery-status`. camelCase on the wire.
2194#[derive(Debug, Default, Clone, Deserialize)]
2195#[serde(default, rename_all = "camelCase")]
2196pub struct DiscoveryStatus {
2197    pub service_id: String,
2198    pub source_url: Option<String>,
2199    pub version: Option<u64>,
2200    /// The regional discovery state, when the deployment declares one. Its
2201    /// shape belongs to the regional protocol, so it is carried opaquely.
2202    pub regional: Option<Value>,
2203    pub upstreams: Vec<DiscoveryUpstream>,
2204    #[serde(flatten)]
2205    pub extra: Extra,
2206}
2207
2208#[derive(Debug, Default, Clone, Deserialize)]
2209#[serde(default, rename_all = "camelCase")]
2210pub struct DiscoveryUpstream {
2211    pub peer: String,
2212    pub draining: bool,
2213    pub in_flight: usize,
2214    #[serde(flatten)]
2215    pub extra: Extra,
2216}
2217
2218// -- namespace plugins ------------------------------------------------------
2219
2220/// A plugin as one namespace sees it, from `GET /namespaces/:ns/plugins`.
2221///
2222/// Only plugins that install per namespace are listed. `enabled` is the
2223/// fleet-wide switch the operator controls; `installed` is this namespace's.
2224/// A plugin does nothing for a namespace unless both are true.
2225#[derive(Debug, Default, Clone, Deserialize)]
2226#[serde(default)]
2227pub struct NamespacePlugin {
2228    pub id: String,
2229    pub name: String,
2230    pub description: String,
2231    pub enabled: bool,
2232    pub installed: bool,
2233    /// Where its dashboard sits on the namespace's surface, if it has one.
2234    pub dashboard: Option<String>,
2235    pub installed_at: Option<u64>,
2236    /// Who installed it — `token:<id>` or `user:<id>`; absent for the operator.
2237    pub installed_by: Option<String>,
2238    pub config: Option<Value>,
2239    #[serde(flatten)]
2240    pub extra: Extra,
2241}
2242
2243/// Every namespace a plugin is installed in, from
2244/// `GET /api/plugins/:id/installs`. Fleet scope only.
2245#[derive(Debug, Default, Clone, Deserialize)]
2246#[serde(default)]
2247pub struct PluginInstalls {
2248    pub plugin: String,
2249    pub enabled: bool,
2250    pub namespaces: Vec<String>,
2251    pub installs: BTreeMap<String, NamespaceInstall>,
2252    #[serde(flatten)]
2253    pub extra: Extra,
2254}
2255
2256#[derive(Debug, Default, Clone, Deserialize)]
2257#[serde(default)]
2258pub struct NamespaceInstall {
2259    pub installed_at: u64,
2260    pub installed_by: Option<String>,
2261    pub config: Value,
2262    #[serde(flatten)]
2263    pub extra: Extra,
2264}
2265
2266// -- telemetry (the obs plugin) ---------------------------------------------
2267
2268/// Whether recently ingested telemetry is queryable yet.
2269///
2270/// app-obs flushes on a timer, so the newest rows are legitimately missing
2271/// from a query until `flush_secs` has passed.
2272#[derive(Debug, Default, Clone, Deserialize)]
2273#[serde(default)]
2274pub struct ObsFreshness {
2275    pub buffered_rows: usize,
2276    pub flush_secs: u64,
2277    /// Records dropped because the ingest buffer was full.
2278    pub dropped: u64,
2279    #[serde(flatten)]
2280    pub extra: Extra,
2281}
2282
2283/// One time bucket of a deployment's metrics.
2284///
2285/// Every measure is optional: `None` means nothing was sampled in the bucket,
2286/// which is different from zero.
2287#[derive(Debug, Default, Clone, Deserialize)]
2288#[serde(default)]
2289pub struct ObsMetricBucket {
2290    /// Bucket start, epoch milliseconds UTC.
2291    pub t: i64,
2292    pub requests_per_sec: Option<f64>,
2293    pub errors_per_sec: Option<f64>,
2294    pub mean_latency_ms: Option<f64>,
2295    /// Cumulative since app-lb started, not windowed.
2296    pub p50_ms: Option<f64>,
2297    pub p90_ms: Option<f64>,
2298    pub p99_ms: Option<f64>,
2299    pub cpu_percent: Option<f64>,
2300    pub memory_bytes: Option<f64>,
2301    pub in_flight: Option<f64>,
2302    pub ready: Option<f64>,
2303    pub pending: Option<f64>,
2304    pub draining: Option<f64>,
2305    #[serde(flatten)]
2306    pub extra: Extra,
2307}
2308
2309/// One time bucket of a deployment's log volume.
2310#[derive(Debug, Default, Clone, Deserialize)]
2311#[serde(default)]
2312pub struct ObsLogBucket {
2313    pub t: i64,
2314    pub lines: u64,
2315    pub errors: u64,
2316    #[serde(flatten)]
2317    pub extra: Extra,
2318}
2319
2320/// A namespace's telemetry overview, from `…/plugins/obs/api/fleet`.
2321#[derive(Debug, Default, Clone, Deserialize)]
2322#[serde(default)]
2323pub struct ObsFleet {
2324    pub generated_at_ms: i64,
2325    pub from_ms: i64,
2326    pub to_ms: i64,
2327    pub step_secs: u32,
2328    /// The window this answer covers, e.g. `1h`.
2329    pub window: String,
2330    /// Every window label the server accepts.
2331    pub windows: Vec<String>,
2332    pub retain_days: u32,
2333    pub freshness: ObsFreshness,
2334    /// Whole-host usage. Operator view only; absent in a namespace's.
2335    pub host: Option<Vec<ObsMetricBucket>>,
2336    pub deployments: Vec<ObsFleetRow>,
2337    /// Sandboxes outside every deployment. Operator view only.
2338    pub host_sandboxes: Option<Value>,
2339    #[serde(flatten)]
2340    pub extra: Extra,
2341}
2342
2343/// One deployment's row in [`ObsFleet`].
2344#[derive(Debug, Default, Clone, Deserialize)]
2345#[serde(default)]
2346pub struct ObsFleetRow {
2347    pub id: String,
2348    pub buckets: Vec<ObsMetricBucket>,
2349    pub log_buckets: Vec<ObsLogBucket>,
2350    /// The most recent non-null value of each measure in the window.
2351    pub latest: ObsMetricBucket,
2352    pub log_lines: u64,
2353    pub error_logs: u64,
2354    #[serde(flatten)]
2355    pub extra: Extra,
2356}
2357
2358/// One deployment's telemetry, from `…/plugins/obs/api/deployments/:id`.
2359#[derive(Debug, Default, Clone, Deserialize)]
2360#[serde(default)]
2361pub struct ObsDeployment {
2362    pub id: String,
2363    pub generated_at_ms: i64,
2364    pub from_ms: i64,
2365    pub to_ms: i64,
2366    pub step_secs: u32,
2367    pub window: String,
2368    pub windows: Vec<String>,
2369    pub retain_days: u32,
2370    pub freshness: ObsFreshness,
2371    pub buckets: Vec<ObsMetricBucket>,
2372    pub log_buckets: Vec<ObsLogBucket>,
2373    pub latest: ObsMetricBucket,
2374    pub log_lines: u64,
2375    pub error_logs: u64,
2376    /// Backends that logged in the window.
2377    pub backends: Vec<String>,
2378    #[serde(flatten)]
2379    pub extra: Extra,
2380}
2381
2382/// One page of a deployment's logs, from
2383/// `…/plugins/obs/api/deployments/:id/logs`. Newest first.
2384#[derive(Debug, Default, Clone, Deserialize)]
2385#[serde(default)]
2386pub struct ObsLogs {
2387    pub id: String,
2388    pub from_ms: i64,
2389    pub to_ms: i64,
2390    pub rows: Vec<ObsLogRow>,
2391    /// Pass back as [`crate::LogQuery::before`] for the next page; `None` at
2392    /// the end. Inclusive, so the next page may repeat lines from the same
2393    /// millisecond — drop the ones already seen.
2394    pub next_before_ms: Option<i64>,
2395    pub limit: usize,
2396    #[serde(flatten)]
2397    pub extra: Extra,
2398}
2399
2400/// One stored log line.
2401#[derive(Debug, Default, Clone, Deserialize)]
2402#[serde(default)]
2403pub struct ObsLogRow {
2404    /// Epoch milliseconds UTC.
2405    pub ts: i64,
2406    pub level: Option<String>,
2407    /// `stdout`, `stderr`, `console`, `access`, `security`, …
2408    pub source: String,
2409    pub message: String,
2410    /// The VM (sandbox id) or upstream that produced it.
2411    pub backend: Option<String>,
2412    pub host: Option<String>,
2413    /// The structured payload, still a JSON string.
2414    pub fields: Option<String>,
2415    #[serde(flatten)]
2416    pub extra: Extra,
2417}
2418
2419/// An alert rule, from `…/plugins/obs/api/alerts`.
2420#[derive(Debug, Default, Clone, Deserialize)]
2421#[serde(default)]
2422pub struct ObsAlert {
2423    pub id: String,
2424    pub deployment: String,
2425    /// The namespace the rule belongs to. Absent on rules made before
2426    /// namespaces existed in app-obs.
2427    pub namespace: Option<String>,
2428    /// `errors` — errors over the trailing minute.
2429    pub metric: String,
2430    pub threshold: f64,
2431    pub webhook_url: String,
2432    #[serde(flatten)]
2433    pub extra: Extra,
2434}
2435
2436// -- the postgres plugin ---------------------------------------------------
2437
2438/// A namespace's Postgres databases, from
2439/// `GET /namespaces/:ns/plugins/postgres/api/databases`. Never carries a
2440/// password.
2441#[derive(Debug, Default, Clone, Deserialize)]
2442#[serde(default)]
2443pub struct PostgresDatabases {
2444    pub namespace: String,
2445    /// Where every database is reached: the region's pg-fc pooler.
2446    pub host: String,
2447    pub port: u16,
2448    pub sslmode: String,
2449    /// The most databases this host lets one namespace have.
2450    pub limit: usize,
2451    pub databases: Vec<PostgresDatabase>,
2452    /// Why pg-fc could not say which databases are live, if it could not.
2453    /// The list itself is still complete.
2454    pub error: Option<String>,
2455    #[serde(flatten)]
2456    pub extra: Extra,
2457}
2458
2459/// One database in [`PostgresDatabases`].
2460#[derive(Debug, Default, Clone, Deserialize)]
2461#[serde(default)]
2462pub struct PostgresDatabase {
2463    /// The name the namespace asked for, e.g. `app`.
2464    pub name: String,
2465    /// The physical database (and role) on pg-fc, e.g. `ns_team_a_app`.
2466    pub database: String,
2467    pub user: String,
2468    pub created_by: Option<String>,
2469    pub created_at: u64,
2470    /// The namespace secret holding its credentials (`pg-<name>`), while it
2471    /// exists.
2472    pub secret: Option<String>,
2473    /// Whether pg-fc still has it. `None` when pg-fc could not be asked.
2474    pub live: Option<bool>,
2475    /// pg-fc's storage tier for it, e.g. `warm`.
2476    pub tier: Option<String>,
2477    #[serde(flatten)]
2478    pub extra: Extra,
2479}
2480
2481/// The reply to creating a database. **`password` (and `url`, which embeds
2482/// it) is the only time the credential is ever returned**; unless the
2483/// database was created without one, it is also in the namespace secret
2484/// named by `secret`. `Debug` leaves both out.
2485#[derive(Default, Clone, Deserialize)]
2486#[serde(default)]
2487pub struct CreatedPostgresDatabase {
2488    pub name: String,
2489    pub database: String,
2490    pub user: String,
2491    pub password: String,
2492    pub host: String,
2493    pub port: u16,
2494    pub sslmode: String,
2495    /// `postgres://user:password@host:port/database?sslmode=…`.
2496    pub url: Option<String>,
2497    /// The namespace secret the credentials were written to (`pg-<name>`).
2498    pub secret: Option<String>,
2499    /// Why the secret could not be written, when it was asked for and was not.
2500    pub secret_error: Option<String>,
2501    /// `provisioning`: the credential works now; the VM comes up in the
2502    /// background and the first connection waits for it.
2503    pub status: String,
2504    pub note: String,
2505    #[serde(flatten)]
2506    pub extra: Extra,
2507}
2508
2509impl std::fmt::Debug for CreatedPostgresDatabase {
2510    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2511        f.debug_struct("CreatedPostgresDatabase")
2512            .field("name", &self.name)
2513            .field("database", &self.database)
2514            .field("user", &self.user)
2515            .field("password", &"<redacted>")
2516            .field("host", &self.host)
2517            .field("port", &self.port)
2518            .field("sslmode", &self.sslmode)
2519            .field("url", &self.url.as_ref().map(|_| "<redacted>"))
2520            .field("secret", &self.secret)
2521            .field("secret_error", &self.secret_error)
2522            .field("status", &self.status)
2523            .finish_non_exhaustive()
2524    }
2525}
2526
2527/// The reply to deleting a database: its credential is revoked. The data
2528/// stays on pg-fc until an operator purges it, and the `pg-<name>` secret is
2529/// left for the namespace to delete.
2530#[derive(Debug, Default, Clone, Deserialize)]
2531#[serde(default)]
2532pub struct DeletedPostgresDatabase {
2533    /// The name that was deleted.
2534    pub deleted: String,
2535    pub database: String,
2536    /// The secret left behind, if any.
2537    pub secret_kept: Option<String>,
2538    pub note: String,
2539    #[serde(flatten)]
2540    pub extra: Extra,
2541}
2542
2543// -- the artifacts plugin --------------------------------------------------
2544
2545/// The namespace's view of the global artifact store, from
2546/// `GET /namespaces/:ns/plugins/artifacts/api`. Never names the store.
2547#[derive(Debug, Default, Clone, Deserialize)]
2548#[serde(default)]
2549pub struct ArtifactStoreInfo {
2550    pub namespace: String,
2551    /// Whether this app-lb fronts a global store at all.
2552    pub available: bool,
2553    /// Whether it holds the store's key, so the namespace can publish.
2554    pub writable: bool,
2555    /// What every tag and repository of the namespace starts with: `<ns>/`.
2556    pub prefix: String,
2557    #[serde(flatten)]
2558    pub extra: Extra,
2559}
2560
2561/// A tag and the digest it points at.
2562#[derive(Debug, Default, Clone, Deserialize)]
2563#[serde(default)]
2564pub struct ArtifactTag {
2565    /// `<ns>/<repo>:<tag>`.
2566    pub tag: String,
2567    /// A 64-hex sha256 of a manifest (or, for `art put --tag`, a blob).
2568    pub digest: String,
2569    #[serde(flatten)]
2570    pub extra: Extra,
2571}
2572
2573/// A repository: the part of a tag before `:`, with what was said about it.
2574#[derive(Debug, Default, Clone, Deserialize)]
2575#[serde(default)]
2576pub struct ArtifactRepo {
2577    /// `<ns>/<repo>`.
2578    pub repo: String,
2579    /// Public repositories are on the hub and pull without a credential.
2580    pub public: bool,
2581    pub description: Option<String>,
2582    /// Unix seconds of the last metadata change.
2583    pub updated: u64,
2584    /// Its tags. Empty in the answer to [`crate::ArtifactsClient::set_repo`].
2585    pub tags: Vec<ArtifactRepoTag>,
2586    #[serde(flatten)]
2587    pub extra: Extra,
2588}
2589
2590/// One tag in an [`ArtifactRepo`].
2591#[derive(Debug, Default, Clone, Deserialize)]
2592#[serde(default)]
2593pub struct ArtifactRepoTag {
2594    /// `<ns>/<repo>:<tag>`.
2595    pub tag: String,
2596    /// The part after `:`.
2597    pub name: String,
2598    pub digest: String,
2599    #[serde(flatten)]
2600    pub extra: Extra,
2601}
2602
2603// -- heyvm's image catalog ------------------------------------------------
2604
2605/// `GET /images`: heyvm's image catalog as app-lb manages it. Fleet scope.
2606#[derive(Debug, Default, Clone, Deserialize)]
2607#[serde(default)]
2608pub struct ImageInventory {
2609    pub generated_at: u64,
2610    /// False when what references the images could not be determined; the
2611    /// server then offloads and deletes nothing.
2612    pub complete: bool,
2613    pub error: Option<String>,
2614    /// Whether heyvm can delete images; `None` until one was tried.
2615    pub delete_supported: Option<bool>,
2616    /// Whether automatic offload is on.
2617    pub offload: bool,
2618    pub disk_used_pct: Option<f64>,
2619    pub pressure_pct: u8,
2620    /// Bytes of unreferenced pulled images, the host's image cache; `None`
2621    /// when the references could not be read.
2622    pub cache_bytes: Option<u64>,
2623    /// `APP_LB_IMAGE_CACHE_BUDGET_GB` in bytes; `None` when only disk
2624    /// pressure evicts the cache.
2625    pub cache_budget_bytes: Option<u64>,
2626    /// Pulls since app-lb started that found their image on heyvm.
2627    pub cache_hits: u64,
2628    /// Pulls since app-lb started that fetched their image.
2629    pub cache_misses: u64,
2630    pub local_bytes: u64,
2631    pub images: Vec<ImageEntry>,
2632    #[serde(flatten)]
2633    pub extra: Extra,
2634}
2635
2636/// One image: its record, and — in an inventory — what holds it.
2637#[derive(Debug, Default, Clone, Deserialize)]
2638#[serde(default)]
2639pub struct ImageEntry {
2640    pub name: String,
2641    /// `pull`, `build` or `unknown`.
2642    pub source: String,
2643    /// `local` or `offloaded`.
2644    pub tier: String,
2645    pub digest: Option<String>,
2646    pub store: Option<String>,
2647    #[serde(rename = "ref")]
2648    pub artifact_ref: Option<String>,
2649    pub grow_gb: Option<u64>,
2650    pub bytes: u64,
2651    pub first_seen: u64,
2652    pub last_used: u64,
2653    pub pinned: bool,
2654    pub offloaded_to: Option<String>,
2655    pub offloaded_at: Option<u64>,
2656    pub failures: u32,
2657    /// After a failed offload, not retried before this.
2658    pub next_attempt_at: u64,
2659    pub last_error: Option<String>,
2660    /// The store's API key, as a secret reference — never a value.
2661    pub auth: Option<Value>,
2662    /// In heyvm's catalog right now. Inventory only.
2663    pub present: bool,
2664    /// What holds it. Inventory only.
2665    pub references: Vec<ImageReference>,
2666    /// Why the offload pacer would leave it alone; absent when it would not.
2667    pub kept_because: Option<String>,
2668    #[serde(flatten)]
2669    pub extra: Extra,
2670}
2671
2672/// Something that holds an image: `deployment`, `rollout`, `sandbox`, `job`,
2673/// `pinned` or `warm`, with the fields that kind carries.
2674#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize)]
2675#[serde(default)]
2676pub struct ImageReference {
2677    pub kind: String,
2678    pub id: Option<String>,
2679    pub deployment: Option<String>,
2680    pub operation: Option<String>,
2681    pub job: Option<String>,
2682    /// The warm-set entry (`<store>/<ref>`) holding a `warm` image.
2683    #[serde(rename = "ref")]
2684    pub artifact_ref: Option<String>,
2685}
2686
2687impl std::fmt::Display for ImageReference {
2688    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2689        match (self.kind.as_str(), &self.id, &self.deployment) {
2690            ("deployment", Some(id), _) => write!(f, "deployment/{id}"),
2691            ("sandbox", Some(id), _) => write!(f, "sandbox/{id}"),
2692            ("rollout", _, Some(d)) => write!(f, "rollout of {d}"),
2693            ("job", _, Some(d)) => write!(f, "job {} of {d}", self.job.as_deref().unwrap_or("?")),
2694            ("warm", _, _) if self.artifact_ref.is_some() => {
2695                write!(f, "warm {}", self.artifact_ref.as_deref().unwrap_or_default())
2696            }
2697            (kind, _, _) => f.write_str(kind),
2698        }
2699    }
2700}
2701
2702/// `POST /images/sweep`.
2703#[derive(Debug, Default, Clone, Deserialize)]
2704#[serde(default)]
2705pub struct ImageSweep {
2706    pub skipped: Option<String>,
2707    pub pressure: bool,
2708    pub offloaded: Vec<String>,
2709    /// `(image, why)`.
2710    pub failed: Vec<(String, String)>,
2711}