1use std::collections::{BTreeMap, BTreeSet};
16
17use serde::{Deserialize, Serialize};
18
19#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum ExposureRequest {
25 #[default]
26 Loopback,
27 Url(String),
29 Tailscale(String),
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum AuthKind {
37 Oidc,
38}
39
40#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum AuthRequested {
44 #[default]
45 No,
46 Yes,
48 Kind(AuthKind),
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct AddRequest {
55 pub service: String,
57 #[serde(default)]
58 pub exposure: ExposureRequest,
59 #[serde(default)]
60 pub auth: AuthRequested,
61 #[serde(default)]
64 pub smtp: Option<bool>,
65 #[serde(default)]
66 pub backup: bool,
67 #[serde(default)]
68 pub env: BTreeMap<String, String>,
69 #[serde(default)]
70 pub enable_groups: BTreeSet<String>,
71 #[serde(default)]
73 pub choose: BTreeMap<String, String>,
74 #[serde(default)]
77 pub allow_unset_required: bool,
78}
79
80impl AddRequest {
81 pub fn new(service: impl Into<String>) -> Self {
83 AddRequest {
84 service: service.into(),
85 exposure: ExposureRequest::default(),
86 auth: AuthRequested::default(),
87 smtp: None,
88 backup: false,
89 env: BTreeMap::new(),
90 enable_groups: BTreeSet::new(),
91 choose: BTreeMap::new(),
92 allow_unset_required: false,
93 }
94 }
95}
96
97#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(rename_all = "snake_case")]
100pub enum RemoveMode {
101 #[default]
103 Preserve,
104 Purge,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct RemoveRequest {
111 pub service: String,
112 #[serde(default)]
113 pub mode: RemoveMode,
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(rename_all = "snake_case")]
119pub enum Lifecycle {
120 Start,
121 Stop,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct LifecycleRequest {
127 pub service: String,
128 pub action: Lifecycle,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct UpgradeRequest {
134 pub service: String,
135 #[serde(default)]
137 pub force: bool,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(rename_all = "snake_case")]
143pub enum ExposureChange {
144 Url(String),
145 Tailscale(String),
146 Loopback,
147}
148
149#[derive(Debug, Clone, Default, Serialize, Deserialize)]
152#[serde(default)]
153pub struct Overrides {
154 pub exposure: Option<ExposureChange>,
155 pub smtp: Option<bool>,
156 pub backup: Option<bool>,
157 pub auth: Option<bool>,
158 pub enable_groups: BTreeSet<String>,
159 pub disable_groups: BTreeSet<String>,
160 pub choose: BTreeMap<String, String>,
161 pub env_overrides: BTreeMap<String, String>,
162 pub reassert_auth: bool,
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct ConfigureRequest {
170 pub service: String,
171 pub changes: Overrides,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
178#[serde(tag = "method", content = "params", rename_all = "snake_case")]
179pub enum Request {
180 Add(AddRequest),
182 Remove(RemoveRequest),
184 Configure(ConfigureRequest),
186 Lifecycle(LifecycleRequest),
188 Upgrade(UpgradeRequest),
190 List,
192 Get { service: String },
194 Diff { service: String },
196 Backups { service: String },
198 Revert {
200 service: String,
201 #[serde(default)]
202 at: Option<String>,
203 },
204 Search {
206 #[serde(default)]
207 query: Option<String>,
208 #[serde(default)]
209 registry: Option<String>,
210 },
211 Registries,
213 AddRegistry { name: String, url: String },
215 RemoveRegistry { name: String },
217 Doctor,
219 Backup { service: String },
221 Restore { service: String, snapshot: String },
223 Snapshots { service: String },
225 BackupStatus,
228 ConfigureBackup {
232 backend: BackupBackendSpec,
233 #[serde(default)]
234 password: Option<String>,
235 },
236 SetBackupEnrolled { service: String, enabled: bool },
238 ServiceDef {
241 service: String,
242 #[serde(default)]
243 registry: Option<String>,
244 },
245 ConfigureView { service: String },
248 Reconcile {
252 #[serde(default)]
253 services: Vec<String>,
254 #[serde(default)]
255 dry_run: bool,
256 },
257 ListTests,
259 RunTest { name: String },
261 TestState,
264 RemoveTestResults {
267 #[serde(default)]
268 name: Option<String>,
269 },
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct BackupOutcome {
275 pub service: String,
276 pub paths: usize,
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize)]
282pub struct RestoreOutcome {
283 pub service: String,
284 pub snapshot: String,
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize)]
290#[serde(rename_all = "snake_case")]
291pub enum BackupBackendSpec {
292 Local { path: String },
294 S3 {
296 endpoint: String,
297 bucket: String,
298 access_key_id: String,
299 secret_access_key: String,
300 #[serde(default)]
301 prefix: Option<String>,
302 },
303 Managed,
307}
308
309#[derive(Debug, Clone, Serialize, Deserialize)]
311pub struct SnapshotView {
312 pub id: String,
314 pub time: String,
316 pub tags: Vec<String>,
318}
319
320#[derive(Debug, Clone, Serialize, Deserialize)]
323pub struct BackupStatusView {
324 pub configured: bool,
326 #[serde(skip_serializing_if = "Option::is_none")]
328 pub backend_label: Option<String>,
329 pub enrolled: Vec<String>,
331}
332
333#[derive(Debug, Clone, Serialize, Deserialize)]
335pub struct EnvKeyChangeView {
336 pub key: String,
337 pub from: Option<String>,
339 pub to: String,
340 pub secret: bool,
342}
343
344#[derive(Debug, Clone, Serialize, Deserialize)]
346pub struct ReconcilePlanView {
347 pub service: String,
348 pub changes: Vec<EnvKeyChangeView>,
349}
350
351#[derive(Debug, Clone, Serialize, Deserialize)]
353pub struct ReconcileOutcome {
354 pub plans: Vec<ReconcilePlanView>,
356 pub applied: usize,
358}
359
360#[derive(Debug, Clone, Serialize, Deserialize)]
362pub struct SearchHit {
363 pub name: String,
364 pub description: String,
365 pub installed: bool,
366 pub supports: Vec<String>,
368 #[serde(default)]
371 pub recommended_ram_mb: Option<u64>,
372}
373
374#[derive(Debug, Clone, Serialize, Deserialize)]
376pub struct RegistryInfo {
377 pub name: String,
378 pub url: String,
379 pub service_count: usize,
380}
381
382#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
384#[serde(rename_all = "snake_case")]
385pub enum Severity {
386 Blocker,
388 Warning,
390 Info,
392}
393
394#[derive(Debug, Clone, Serialize, Deserialize)]
396pub struct DoctorIssue {
397 pub code: String,
399 pub severity: Severity,
400 pub message: String,
403 #[serde(skip_serializing_if = "Option::is_none")]
405 pub service: Option<String>,
406}
407
408#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
410#[serde(rename_all = "snake_case")]
411pub enum DiffKind {
412 Unchanged,
413 Modified,
414 Drift,
416 Added,
417 Removed,
418}
419
420#[derive(Debug, Clone, Serialize, Deserialize)]
422pub struct DiffEntry {
423 pub path: String,
424 pub kind: DiffKind,
425}
426
427#[derive(Debug, Clone, Serialize, Deserialize)]
429pub struct EnvAddition {
430 pub key: String,
431 pub kind: String,
433 #[serde(skip_serializing_if = "Option::is_none")]
434 pub prompt: Option<String>,
435}
436
437#[derive(Debug, Clone, Serialize, Deserialize)]
439pub struct DiffView {
440 pub service: String,
441 pub upgrade_available: bool,
443 pub blocked_by_drift: bool,
445 pub source_stale: bool,
447 pub entries: Vec<DiffEntry>,
449 pub env_additions: Vec<EnvAddition>,
451}
452
453#[derive(Debug, Clone, Serialize, Deserialize)]
455pub struct BackupSnapshotView {
456 pub timestamp: String,
458}
459
460#[derive(Debug, Clone, Serialize, Deserialize)]
462pub struct RevertOutcome {
463 pub service: String,
464 pub timestamp: String,
466 pub files_restored: usize,
467 pub files_deleted: usize,
468}
469
470#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
472#[serde(rename_all = "snake_case")]
473pub enum ServiceState {
474 Running,
475 Stopped,
476 Installing,
480 Removed,
482}
483
484#[derive(Debug, Clone, Serialize, Deserialize)]
487pub struct ServiceView {
488 pub name: String,
489 pub state: ServiceState,
490 #[serde(skip_serializing_if = "Option::is_none")]
492 pub url: Option<String>,
493 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
495 pub ports: BTreeMap<String, u16>,
496 #[serde(skip_serializing_if = "Option::is_none")]
498 pub registry: Option<String>,
499 #[serde(skip_serializing_if = "Option::is_none")]
501 pub version: Option<String>,
502 #[serde(default)]
504 pub upgrade_available: bool,
505}
506
507#[derive(Debug, Clone, Serialize, Deserialize)]
511pub struct ApplyOutcome {
512 pub service: ServiceView,
513 pub applied: usize,
514 #[serde(default)]
515 pub destructive: bool,
516}
517
518#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
523#[serde(rename_all = "snake_case")]
524pub enum EnvKindView {
525 Default,
526 Prompted,
527 Required,
528}
529
530#[derive(Debug, Clone, Serialize, Deserialize)]
533pub struct EnvVarView {
534 pub name: String,
535 pub kind: EnvKindView,
536 #[serde(skip_serializing_if = "Option::is_none")]
537 pub prompt: Option<String>,
538 pub format: String,
540 pub generated: bool,
542 pub value_empty: bool,
544}
545
546#[derive(Debug, Clone, Serialize, Deserialize)]
548pub struct EnvGroupView {
549 pub name: String,
550 pub prompt: String,
551 pub env: Vec<EnvVarView>,
552}
553
554#[derive(Debug, Clone, Serialize, Deserialize)]
556pub struct ChoiceOptionView {
557 pub name: String,
558 #[serde(skip_serializing_if = "Option::is_none")]
559 pub label: Option<String>,
560 pub env: Vec<EnvVarView>,
561}
562
563#[derive(Debug, Clone, Serialize, Deserialize)]
565pub struct ChoiceView {
566 pub name: String,
567 pub prompt: String,
568 pub default: String,
569 pub options: Vec<ChoiceOptionView>,
570}
571
572#[derive(Debug, Clone, Serialize, Deserialize)]
574pub struct ServiceDefView {
575 pub name: String,
576 pub env: Vec<EnvVarView>,
577 pub env_groups: Vec<EnvGroupView>,
578 pub choices: Vec<ChoiceView>,
579}
580
581#[derive(Debug, Clone, Serialize, Deserialize)]
584pub struct ConfigureView {
585 pub name: String,
586 pub def: ServiceDefView,
587 pub selected_choices: BTreeMap<String, String>,
589 pub enabled_groups: Vec<String>,
591 pub current_env: BTreeMap<String, String>,
593}
594
595#[derive(Debug, Clone, Serialize, Deserialize)]
597pub struct RegistryTestView {
598 pub name: String,
599 pub kind: String,
601 pub services: Vec<String>,
602 pub step_count: usize,
603 pub step_kinds: Vec<String>,
604 pub needs_browser: bool,
605 pub requires_sudo: bool,
606}
607
608#[derive(Debug, Clone, Serialize, Deserialize)]
610pub struct TestRunView {
611 pub name: String,
612 pub passed: bool,
613 pub duration_secs: f64,
614 pub outcome: String,
616 pub events: Vec<TestEventView>,
617}
618
619#[derive(Debug, Clone, Serialize, Deserialize)]
621pub struct TestEventView {
622 pub description: String,
623 pub kind: String,
625 pub passed: bool,
626 pub skipped: bool,
627 pub error: Option<String>,
628 pub duration_secs: f64,
629 pub stdout: String,
630 pub stderr: String,
631}
632
633#[derive(Debug, Clone, Serialize, Deserialize)]
635pub struct TestStateView {
636 pub sandbox_path: String,
637 pub tests: Vec<TestResultEntryView>,
638}
639
640#[derive(Debug, Clone, Serialize, Deserialize)]
642pub struct TestResultEntryView {
643 pub name: String,
644 pub status: String,
645 pub duration_ms: u64,
646 pub timestamp: u64,
647 pub has_playwright: bool,
648}
649
650#[derive(Debug, Clone, Serialize, Deserialize)]
652#[serde(rename_all = "snake_case")]
653pub enum Response {
654 Applied(ApplyOutcome),
656 Service(ServiceView),
658 Services(Vec<ServiceView>),
660 Diff(DiffView),
662 Backups(Vec<BackupSnapshotView>),
664 Revert(RevertOutcome),
666 SearchResults(Vec<SearchHit>),
668 Registries(Vec<RegistryInfo>),
670 Doctor(Vec<DoctorIssue>),
672 Backup(BackupOutcome),
674 Restore(RestoreOutcome),
676 Snapshots(Vec<SnapshotView>),
678 BackupStatus(BackupStatusView),
680 ServiceDef(ServiceDefView),
682 ConfigureView(ConfigureView),
684 Reconcile(ReconcileOutcome),
686 Tests(Vec<RegistryTestView>),
688 TestRun(TestRunView),
690 TestState(TestStateView),
692 Done,
694}
695
696#[derive(Debug, Clone, Serialize, Deserialize)]
699#[serde(rename_all = "snake_case")]
700pub enum Reply {
701 Ok(Response),
702 Error(RpcError),
703}
704
705#[derive(Debug, Clone, Serialize, Deserialize)]
707pub struct RpcError {
708 pub code: ErrorCode,
709 pub message: String,
710}
711
712#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
714#[serde(rename_all = "snake_case")]
715pub enum ErrorCode {
716 BadRequest,
717 NotFound,
718 Conflict,
719 Internal,
720}
721
722impl RpcError {
723 pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
724 RpcError {
725 code,
726 message: message.into(),
727 }
728 }
729}
730
731#[cfg(test)]
732mod tests {
733 use super::*;
734
735 #[test]
736 fn request_maps_to_method_and_params() {
737 let req = Request::Add(AddRequest::new("forgejo"));
738 let v = serde_json::to_value(&req).unwrap();
739 assert_eq!(v["method"], "add");
740 assert_eq!(v["params"]["service"], "forgejo");
741 }
742
743 #[test]
744 fn unit_request_has_no_params() {
745 let v = serde_json::to_value(Request::List).unwrap();
746 assert_eq!(v["method"], "list");
747 assert!(v.get("params").is_none());
748 }
749
750 #[test]
751 fn service_view_round_trips_and_omits_empties() {
752 let view = ServiceView {
753 name: "forgejo".to_string(),
754 state: ServiceState::Running,
755 url: Some("https://forgejo.example.com".to_string()),
756 ports: BTreeMap::new(),
757 registry: None,
758 version: None,
759 upgrade_available: false,
760 };
761 let v = serde_json::to_value(&view).unwrap();
762 assert!(v.get("ports").is_none());
763 assert_eq!(v["state"], "running");
764 let back: ServiceView = serde_json::from_value(v).unwrap();
765 assert_eq!(back.name, "forgejo");
766 assert_eq!(back.state, ServiceState::Running);
767 }
768}