1use serde::Deserialize;
24use serde_json::Value;
25use std::collections::BTreeMap;
26
27pub type Extra = serde_json::Map<String, Value>;
32
33#[derive(Debug, Default, Deserialize)]
36#[serde(default)]
37pub struct DeploymentStatus {
38 pub rollout_revision: String,
39 pub spec: DeploymentSpec,
40 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 pub workspace: Option<WorkspaceStatus>,
50 pub site: Option<SiteRootStatus>,
52 #[serde(flatten)]
53 pub extra: Extra,
54}
55
56#[derive(Debug, Default, Clone, Deserialize)]
58#[serde(default)]
59pub struct SiteRootStatus {
60 pub root: String,
61 pub status: String,
63 pub index_present: Option<bool>,
64 pub hint: Option<String>,
66}
67
68#[derive(Debug, Default, Clone, Deserialize)]
70#[serde(default)]
71pub struct WorkspaceSpec {
72 pub path: Option<String>,
74 pub store: String,
75 #[serde(rename = "ref")]
76 pub artifact_ref: Option<String>,
77 pub auth: Option<SecretRef>,
78 pub snapshot_interval_secs: Option<u64>,
81}
82
83#[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 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 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 pub namespace: String,
149 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 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#[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 pub fn is_static(&self) -> bool {
201 !self.upstreams.is_empty() || self.discovery.is_some()
202 }
203
204 pub fn is_site(&self) -> bool {
206 self.site.is_some()
207 }
208
209 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 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 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 pub mounts: Vec<MountSpec>,
325 pub workspace: Option<WorkspaceSpec>,
328 pub ttl_seconds: u64,
329}
330
331#[derive(Debug, Default, Clone, Deserialize)]
338#[serde(default)]
339pub struct MountSpec {
340 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 pub read_only: bool,
349 pub digest: Option<String>,
353}
354
355impl MountSpec {
356 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#[derive(Debug, Default, Clone, Deserialize)]
379#[serde(default)]
380pub struct BuildSpec {
381 pub repo: String,
385 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 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 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#[derive(Debug, Default, Clone, Deserialize)]
435#[serde(default)]
436pub struct ArtifactSpec {
437 #[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 pub strip_components: Option<usize>,
447}
448
449impl ArtifactSpec {
450 pub fn summary(&self) -> String {
455 if self.uses_lb_store() {
456 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 pub fn uses_lb_store(&self) -> bool {
469 self.store.trim().is_empty()
470 }
471}
472
473fn 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#[derive(Debug, Default, Clone, Deserialize)]
480#[serde(default)]
481pub struct MountOutcome {
482 pub path: String,
484 pub store: String,
485 #[serde(rename = "ref")]
486 pub artifact_ref: String,
487 pub digest: Option<String>,
488 pub tree: Option<String>,
490 pub files: Option<usize>,
491 pub bytes: Option<u64>,
494 pub unpacked: Option<u64>,
496 pub reused: bool,
497 pub changed: bool,
499}
500
501impl MountOutcome {
502 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 pub namespace: Option<String>,
526}
527
528impl SecretRef {
529 pub fn render(&self) -> String {
530 format!("{}/{}", self.secret, self.key)
531 }
532
533 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#[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 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#[derive(Debug, Clone, Deserialize)]
575pub struct PublicPath {
576 pub path: String,
577 pub scope: String,
580}
581
582#[derive(Debug, Default, Clone, Deserialize)]
584#[serde(default)]
585pub struct AuthGate {
586 pub provider: Value,
592 pub client_id: Option<String>,
594 pub client_secret: Option<SecretRef>,
595 pub allowed_domains: Vec<String>,
596 pub allowed_emails: Vec<String>,
597 pub public_paths: Vec<PublicPath>,
605 pub session_scope: Option<String>,
609 pub base_path: String,
610 pub session_ttl_secs: u64,
611 pub cookie_name: String,
612 pub cookie_domain: Option<String>,
615 pub redirect_url: Option<String>,
616 pub forward_identity: bool,
617 pub jwt: Option<JwtSpec>,
619 pub provider_ref: Option<String>,
624}
625
626#[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 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 pub login_url: Option<String>,
650 pub login_redirect_param: Option<String>,
653 pub authorize_url: Option<String>,
657 pub token_url: Option<String>,
659}
660
661impl JwtSpec {
662 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 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
696fn 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 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 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 pub fn accepts_app_token(&self) -> bool {
739 self.providers().iter().any(|p| p == "app-token")
740 }
741
742 pub fn accepts_jwt(&self) -> bool {
744 self.providers().iter().any(|p| p == "jwt")
745 }
746
747 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#[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 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 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 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 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 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#[derive(Debug, Default, Deserialize)]
866#[serde(default)]
867pub struct SecretSummary {
868 pub id: String,
869 pub description: Option<String>,
870 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#[derive(Debug, Default, Deserialize)]
888#[serde(default)]
889pub struct WorkflowView {
890 pub id: String,
891 pub repo: String,
892 #[serde(rename = "ref")]
894 pub git_ref: String,
895 pub path: String,
896 pub network: String,
897 pub auth: Option<Value>,
899 pub secrets_prefix: Option<String>,
900 pub enabled: bool,
901 #[serde(flatten)]
902 pub extra: Extra,
903}
904
905#[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#[derive(Debug, Default, Deserialize)]
921#[serde(default)]
922pub struct JobRecord {
923 pub id: String,
924 pub deployment: String,
925 pub kind: String,
927 pub status: String,
929 pub started_at: u64,
930 pub finished_at: Option<u64>,
931
932 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 pub store: Option<String>,
943 #[serde(rename = "artifact")]
946 pub artifact_ref: Option<String>,
947 pub digest: Option<String>,
949 pub bytes: Option<u64>,
952 pub reused: bool,
953 pub site_root: Option<String>,
956 pub files: Option<usize>,
959
960 pub mounts: Vec<MountOutcome>,
965
966 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 pub fn is_pull(&self) -> bool {
996 self.kind == "artifact-pull"
997 }
998
999 pub fn is_mount_pull(&self) -> bool {
1004 self.kind == "mount-pull"
1005 }
1006
1007 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 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 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 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 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 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 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#[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 pub obs: Option<ObsStats>,
1119 pub security: Option<SecuritySummary>,
1121 pub daemon: DaemonStatus,
1125 pub deployments: Vec<DeploymentView>,
1126 pub matched: usize,
1129 pub tracked_deployments: usize,
1132 #[serde(default)]
1138 pub host_sandboxes: Vec<HostSandboxView>,
1139 #[serde(flatten)]
1140 pub extra: Extra,
1141}
1142
1143#[derive(Debug, Default, Deserialize)]
1145#[serde(default)]
1146pub struct HostSandboxView {
1147 pub sandbox_id: String,
1148 pub name: String,
1149 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 pub account_id: Option<String>,
1159 pub created_at: Option<String>,
1161 #[serde(flatten)]
1162 pub extra: Extra,
1163}
1164
1165#[derive(Debug, Default, Deserialize)]
1169#[serde(default)]
1170pub struct SecuritySummary {
1171 pub open: usize,
1173 pub urgent: u64,
1175 pub dropped: u64,
1179 pub clients_at_capacity: bool,
1182 pub rules: usize,
1186 pub blocked: u64,
1187 #[serde(flatten)]
1188 pub extra: Extra,
1189}
1190
1191#[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 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 pub namespace: String,
1237 pub kind: String,
1239 pub upstreams: Vec<String>,
1240 pub routed: bool,
1242 pub hosts: Vec<String>,
1245 pub urls: Vec<String>,
1248 pub site_root: Option<String>,
1250 pub site_spa: bool,
1251 pub job_kind: Option<String>,
1254 pub pool: PoolStatus,
1255 pub vms: Vec<VmView>,
1256 pub pending_vms: Vec<PendingVmView>,
1260 pub metrics: DeploymentMetrics,
1261 #[serde(flatten)]
1262 pub extra: Extra,
1263}
1264
1265#[derive(Debug, Default, Deserialize)]
1267#[serde(default)]
1268pub struct PendingVmView {
1269 pub sandbox_id: String,
1270 pub age_secs: u64,
1272 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 pub utilization: Option<f64>,
1295 pub cpu_percent: Option<f64>,
1296 pub memory_bytes: Option<u64>,
1297 pub boot_timeout_secs: u64,
1300 pub cold_start_timeout_secs: u64,
1303 pub boot_failures: u64,
1305 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 pub boot_timeouts: u64,
1393 pub create_failures: u64,
1397 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#[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#[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#[derive(Debug, Default, Deserialize)]
1457#[serde(default)]
1458pub struct DiskPart {
1459 pub kind: String,
1460 pub path: String,
1461 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#[derive(Debug, Default, Deserialize)]
1500#[serde(default)]
1501pub struct DiskInfo {
1502 pub sandbox_id: String,
1503 pub name: Option<String>,
1505 pub deployment: Option<String>,
1508 pub state: DiskState,
1509 pub claimed: bool,
1512 pub retain: bool,
1514 pub note: Option<String>,
1515 pub bytes: u64,
1516 pub apparent_bytes: u64,
1517 pub modified_at: u64,
1518 pub expires_at: Option<u64>,
1520 pub held_by: Option<String>,
1522 pub archived: Option<DiskArchiveRecord>,
1523 pub parts: Vec<DiskPart>,
1524 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 pub expiring_now: usize,
1542 pub reclaimable_bytes: u64,
1543 #[serde(flatten)]
1544 pub extra: Extra,
1545}
1546
1547#[derive(Debug, Default, Deserialize)]
1549#[serde(default)]
1550pub struct DiskInventory {
1551 pub complete: bool,
1555 pub incomplete_reason: Option<String>,
1556 pub data_dir: String,
1557 pub tmp_dir: String,
1558 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 pub free_bytes: Option<u64>,
1567 pub filesystem_bytes: Option<u64>,
1568 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#[derive(Debug, Default, Clone, Deserialize)]
1582#[serde(default)]
1583pub struct PluginView {
1584 pub id: String,
1585 pub name: String,
1586 pub description: String,
1587 pub config_schema: Value,
1589 pub per_namespace: bool,
1592 pub installed_in: Vec<String>,
1594 pub dashboard: Option<String>,
1598 pub enabled: bool,
1599 pub config: Value,
1601 pub updated_at: u64,
1602 pub last_error: Option<String>,
1604 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 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 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 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 #[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 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 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 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 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 let any = AuthGate {
1836 allowed_domains: vec!["*".into()],
1837 ..Default::default()
1838 };
1839 assert_eq!(any.allow_summary(), "any Google account");
1840
1841 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#[derive(Debug, Default, Deserialize)]
1881#[serde(default)]
1882pub struct ExecOutput {
1883 pub sandbox_id: String,
1886 pub exit_code: i32,
1887 pub stdout: String,
1888 pub stderr: String,
1889 pub output: String,
1892 #[serde(flatten)]
1893 pub extra: Extra,
1894}
1895
1896impl ExecOutput {
1897 pub fn ok(&self) -> bool {
1899 self.exit_code == 0
1900 }
1901}
1902
1903#[derive(Debug, Default, Deserialize)]
1906#[serde(default)]
1907pub struct EvictOutcome {
1908 pub sandbox_id: String,
1909 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#[derive(Debug, Default, Deserialize, serde::Serialize)]
1924#[serde(default)]
1925pub struct UpstreamTrafficStatus {
1926 pub deployment_id: String,
1927 pub upstream: String,
1928 pub state: String,
1930 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, serde::Serialize)]
1953#[serde(rename_all = "lowercase")]
1954pub enum AdminScope {
1955 #[default]
1958 None,
1959 View,
1961 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 pub namespace: Option<String>,
1985 pub deployments: Vec<String>,
1987 pub created_at: u64,
1988 pub expires_at: Option<u64>,
1989 pub last_used_at: Option<u64>,
1993 pub fleet: bool,
1995 pub mirrored_from: Option<String>,
1998 #[serde(flatten)]
1999 pub extra: Extra,
2000}
2001
2002impl TokenSummary {
2003 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#[derive(Debug, Default, Clone, Deserialize)]
2024#[serde(default)]
2025pub struct NamespaceEntry {
2026 pub namespace: String,
2027 pub deployments: u64,
2030 pub declared: bool,
2034 pub description: Option<String>,
2035 pub created_at: Option<u64>,
2037 #[serde(flatten)]
2038 pub extra: Extra,
2039}
2040
2041#[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#[derive(Debug, Default, Clone, Deserialize)]
2057#[serde(default)]
2058pub struct FeedEvent {
2059 pub id: u64,
2060 pub ts: u64,
2062 pub last_ts: u64,
2064 pub count: u64,
2065 pub namespace: String,
2066 pub deployment: String,
2067 pub kind: String,
2069 pub title: String,
2070 pub detail: String,
2071 #[serde(flatten)]
2072 pub extra: Extra,
2073}
2074
2075#[derive(Debug, Default, Clone, Deserialize)]
2079pub struct MintedToken {
2080 #[serde(flatten)]
2081 pub summary: TokenSummary,
2082 pub token: String,
2083}
2084
2085#[derive(Debug, Default, Clone, Deserialize)]
2092#[serde(default)]
2093pub struct WhoAmI {
2094 pub caller: String,
2096 pub admin_scope: String,
2099 pub fleet: bool,
2101 pub confined: bool,
2103 pub may: WhoAmIMay,
2104 pub token: Option<WhoAmIToken>,
2106 pub namespace: Option<String>,
2108 pub deployments: Option<Vec<String>>,
2111 pub expires_at: Option<u64>,
2112 pub expires_in_secs: Option<u64>,
2113 pub subject: Option<Value>,
2115 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 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#[derive(Debug, Default, Clone, Deserialize)]
2163#[serde(default)]
2164pub struct RolloutOperation {
2165 pub operation_id: String,
2166 pub deployment: String,
2167 pub source_revision: String,
2169 pub target_spec_sha256: String,
2170 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 pub failure_settlement: Option<Value>,
2179 #[serde(flatten)]
2180 pub extra: Extra,
2181}
2182
2183impl RolloutOperation {
2184 pub fn is_finished(&self) -> bool {
2186 !matches!(self.status.as_str(), "running" | "reconciliation_required")
2187 }
2188}
2189
2190#[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 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#[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 pub dashboard: Option<String>,
2235 pub installed_at: Option<u64>,
2236 pub installed_by: Option<String>,
2238 pub config: Option<Value>,
2239 #[serde(flatten)]
2240 pub extra: Extra,
2241}
2242
2243#[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#[derive(Debug, Default, Clone, Deserialize)]
2273#[serde(default)]
2274pub struct ObsFreshness {
2275 pub buffered_rows: usize,
2276 pub flush_secs: u64,
2277 pub dropped: u64,
2279 #[serde(flatten)]
2280 pub extra: Extra,
2281}
2282
2283#[derive(Debug, Default, Clone, Deserialize)]
2288#[serde(default)]
2289pub struct ObsMetricBucket {
2290 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 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#[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#[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 pub window: String,
2330 pub windows: Vec<String>,
2332 pub retain_days: u32,
2333 pub freshness: ObsFreshness,
2334 pub host: Option<Vec<ObsMetricBucket>>,
2336 pub deployments: Vec<ObsFleetRow>,
2337 pub host_sandboxes: Option<Value>,
2339 #[serde(flatten)]
2340 pub extra: Extra,
2341}
2342
2343#[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 pub latest: ObsMetricBucket,
2352 pub log_lines: u64,
2353 pub error_logs: u64,
2354 #[serde(flatten)]
2355 pub extra: Extra,
2356}
2357
2358#[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 pub backends: Vec<String>,
2378 #[serde(flatten)]
2379 pub extra: Extra,
2380}
2381
2382#[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 pub next_before_ms: Option<i64>,
2395 pub limit: usize,
2396 #[serde(flatten)]
2397 pub extra: Extra,
2398}
2399
2400#[derive(Debug, Default, Clone, Deserialize)]
2402#[serde(default)]
2403pub struct ObsLogRow {
2404 pub ts: i64,
2406 pub level: Option<String>,
2407 pub source: String,
2409 pub message: String,
2410 pub backend: Option<String>,
2412 pub host: Option<String>,
2413 pub fields: Option<String>,
2415 #[serde(flatten)]
2416 pub extra: Extra,
2417}
2418
2419#[derive(Debug, Default, Clone, Deserialize)]
2421#[serde(default)]
2422pub struct ObsAlert {
2423 pub id: String,
2424 pub deployment: String,
2425 pub namespace: Option<String>,
2428 pub metric: String,
2430 pub threshold: f64,
2431 pub webhook_url: String,
2432 #[serde(flatten)]
2433 pub extra: Extra,
2434}
2435
2436#[derive(Debug, Default, Clone, Deserialize)]
2442#[serde(default)]
2443pub struct PostgresDatabases {
2444 pub namespace: String,
2445 pub host: String,
2447 pub port: u16,
2448 pub sslmode: String,
2449 pub limit: usize,
2451 pub databases: Vec<PostgresDatabase>,
2452 pub error: Option<String>,
2455 #[serde(flatten)]
2456 pub extra: Extra,
2457}
2458
2459#[derive(Debug, Default, Clone, Deserialize)]
2461#[serde(default)]
2462pub struct PostgresDatabase {
2463 pub name: String,
2465 pub database: String,
2467 pub user: String,
2468 pub created_by: Option<String>,
2469 pub created_at: u64,
2470 pub secret: Option<String>,
2473 pub live: Option<bool>,
2475 pub tier: Option<String>,
2477 #[serde(flatten)]
2478 pub extra: Extra,
2479}
2480
2481#[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 pub url: Option<String>,
2497 pub secret: Option<String>,
2499 pub secret_error: Option<String>,
2501 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#[derive(Debug, Default, Clone, Deserialize)]
2531#[serde(default)]
2532pub struct DeletedPostgresDatabase {
2533 pub deleted: String,
2535 pub database: String,
2536 pub secret_kept: Option<String>,
2538 pub note: String,
2539 #[serde(flatten)]
2540 pub extra: Extra,
2541}
2542
2543#[derive(Debug, Default, Clone, Deserialize)]
2548#[serde(default)]
2549pub struct ArtifactStoreInfo {
2550 pub namespace: String,
2551 pub available: bool,
2553 pub writable: bool,
2555 pub prefix: String,
2557 #[serde(flatten)]
2558 pub extra: Extra,
2559}
2560
2561#[derive(Debug, Default, Clone, Deserialize)]
2563#[serde(default)]
2564pub struct ArtifactTag {
2565 pub tag: String,
2567 pub digest: String,
2569 #[serde(flatten)]
2570 pub extra: Extra,
2571}
2572
2573#[derive(Debug, Default, Clone, Deserialize)]
2575#[serde(default)]
2576pub struct ArtifactRepo {
2577 pub repo: String,
2579 pub public: bool,
2581 pub description: Option<String>,
2582 pub updated: u64,
2584 pub tags: Vec<ArtifactRepoTag>,
2586 #[serde(flatten)]
2587 pub extra: Extra,
2588}
2589
2590#[derive(Debug, Default, Clone, Deserialize)]
2592#[serde(default)]
2593pub struct ArtifactRepoTag {
2594 pub tag: String,
2596 pub name: String,
2598 pub digest: String,
2599 #[serde(flatten)]
2600 pub extra: Extra,
2601}
2602
2603#[derive(Debug, Default, Clone, Deserialize)]
2607#[serde(default)]
2608pub struct ImageInventory {
2609 pub generated_at: u64,
2610 pub complete: bool,
2613 pub error: Option<String>,
2614 pub delete_supported: Option<bool>,
2616 pub offload: bool,
2618 pub disk_used_pct: Option<f64>,
2619 pub pressure_pct: u8,
2620 pub cache_bytes: Option<u64>,
2623 pub cache_budget_bytes: Option<u64>,
2626 pub cache_hits: u64,
2628 pub cache_misses: u64,
2630 pub local_bytes: u64,
2631 pub images: Vec<ImageEntry>,
2632 #[serde(flatten)]
2633 pub extra: Extra,
2634}
2635
2636#[derive(Debug, Default, Clone, Deserialize)]
2638#[serde(default)]
2639pub struct ImageEntry {
2640 pub name: String,
2641 pub source: String,
2643 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 pub next_attempt_at: u64,
2659 pub last_error: Option<String>,
2660 pub auth: Option<Value>,
2662 pub present: bool,
2664 pub references: Vec<ImageReference>,
2666 pub kept_because: Option<String>,
2668 #[serde(flatten)]
2669 pub extra: Extra,
2670}
2671
2672#[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 #[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#[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 pub failed: Vec<(String, String)>,
2711}