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}
75
76impl AddRequest {
77 pub fn new(service: impl Into<String>) -> Self {
79 AddRequest {
80 service: service.into(),
81 exposure: ExposureRequest::default(),
82 auth: AuthRequested::default(),
83 smtp: None,
84 backup: false,
85 env: BTreeMap::new(),
86 enable_groups: BTreeSet::new(),
87 choose: BTreeMap::new(),
88 }
89 }
90}
91
92#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "snake_case")]
95pub enum RemoveMode {
96 #[default]
98 Preserve,
99 Purge,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct RemoveRequest {
106 pub service: String,
107 #[serde(default)]
108 pub mode: RemoveMode,
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(rename_all = "snake_case")]
114pub enum Lifecycle {
115 Start,
116 Stop,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct LifecycleRequest {
122 pub service: String,
123 pub action: Lifecycle,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct UpgradeRequest {
129 pub service: String,
130 #[serde(default)]
132 pub force: bool,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(rename_all = "snake_case")]
138pub enum ExposureChange {
139 Url(String),
140 Tailscale(String),
141 Loopback,
142}
143
144#[derive(Debug, Clone, Default, Serialize, Deserialize)]
147#[serde(default)]
148pub struct Overrides {
149 pub exposure: Option<ExposureChange>,
150 pub smtp: Option<bool>,
151 pub backup: Option<bool>,
152 pub auth: Option<bool>,
153 pub enable_groups: BTreeSet<String>,
154 pub disable_groups: BTreeSet<String>,
155 pub choose: BTreeMap<String, String>,
156 pub env_overrides: BTreeMap<String, String>,
157 pub reassert_auth: bool,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct ConfigureRequest {
165 pub service: String,
166 pub changes: Overrides,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
173#[serde(tag = "method", content = "params", rename_all = "snake_case")]
174pub enum Request {
175 Add(AddRequest),
177 Remove(RemoveRequest),
179 Configure(ConfigureRequest),
181 Lifecycle(LifecycleRequest),
183 Upgrade(UpgradeRequest),
185 List,
187 Get { service: String },
189 Diff { service: String },
191 Backups { service: String },
193 Revert {
195 service: String,
196 #[serde(default)]
197 at: Option<String>,
198 },
199 Search {
201 #[serde(default)]
202 query: Option<String>,
203 #[serde(default)]
204 registry: Option<String>,
205 },
206 Registries,
208 AddRegistry { name: String, url: String },
210 RemoveRegistry { name: String },
212 Doctor,
214 Backup { service: String },
216 Restore { service: String, snapshot: String },
218 Snapshots { service: String },
220 BackupStatus,
223 ConfigureBackup {
227 backend: BackupBackendSpec,
228 #[serde(default)]
229 password: Option<String>,
230 },
231 SetBackupEnrolled { service: String, enabled: bool },
233 ServiceDef {
236 service: String,
237 #[serde(default)]
238 registry: Option<String>,
239 },
240 ConfigureView { service: String },
243 Reconcile {
247 #[serde(default)]
248 services: Vec<String>,
249 #[serde(default)]
250 dry_run: bool,
251 },
252 ListTests,
254 RunTest { name: String },
256 TestState,
259 RemoveTestResults {
262 #[serde(default)]
263 name: Option<String>,
264 },
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize)]
269pub struct BackupOutcome {
270 pub service: String,
271 pub paths: usize,
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct RestoreOutcome {
278 pub service: String,
279 pub snapshot: String,
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize)]
285#[serde(rename_all = "snake_case")]
286pub enum BackupBackendSpec {
287 Local { path: String },
289 S3 {
291 endpoint: String,
292 bucket: String,
293 access_key_id: String,
294 secret_access_key: String,
295 #[serde(default)]
296 prefix: Option<String>,
297 },
298}
299
300#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct SnapshotView {
303 pub id: String,
305 pub time: String,
307 pub tags: Vec<String>,
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct BackupStatusView {
315 pub configured: bool,
317 #[serde(skip_serializing_if = "Option::is_none")]
319 pub backend_label: Option<String>,
320 pub enrolled: Vec<String>,
322}
323
324#[derive(Debug, Clone, Serialize, Deserialize)]
326pub struct EnvKeyChangeView {
327 pub key: String,
328 pub from: Option<String>,
330 pub to: String,
331 pub secret: bool,
333}
334
335#[derive(Debug, Clone, Serialize, Deserialize)]
337pub struct ReconcilePlanView {
338 pub service: String,
339 pub changes: Vec<EnvKeyChangeView>,
340}
341
342#[derive(Debug, Clone, Serialize, Deserialize)]
344pub struct ReconcileOutcome {
345 pub plans: Vec<ReconcilePlanView>,
347 pub applied: usize,
349}
350
351#[derive(Debug, Clone, Serialize, Deserialize)]
353pub struct SearchHit {
354 pub name: String,
355 pub description: String,
356 pub installed: bool,
357 pub supports: Vec<String>,
359 #[serde(default)]
362 pub recommended_ram_mb: Option<u64>,
363}
364
365#[derive(Debug, Clone, Serialize, Deserialize)]
367pub struct RegistryInfo {
368 pub name: String,
369 pub url: String,
370 pub service_count: usize,
371}
372
373#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
375#[serde(rename_all = "snake_case")]
376pub enum Severity {
377 Blocker,
379 Warning,
381 Info,
383}
384
385#[derive(Debug, Clone, Serialize, Deserialize)]
387pub struct DoctorIssue {
388 pub code: String,
390 pub severity: Severity,
391 pub message: String,
394 #[serde(skip_serializing_if = "Option::is_none")]
396 pub service: Option<String>,
397}
398
399#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
401#[serde(rename_all = "snake_case")]
402pub enum DiffKind {
403 Unchanged,
404 Modified,
405 Drift,
407 Added,
408 Removed,
409}
410
411#[derive(Debug, Clone, Serialize, Deserialize)]
413pub struct DiffEntry {
414 pub path: String,
415 pub kind: DiffKind,
416}
417
418#[derive(Debug, Clone, Serialize, Deserialize)]
420pub struct EnvAddition {
421 pub key: String,
422 pub kind: String,
424 #[serde(skip_serializing_if = "Option::is_none")]
425 pub prompt: Option<String>,
426}
427
428#[derive(Debug, Clone, Serialize, Deserialize)]
430pub struct DiffView {
431 pub service: String,
432 pub upgrade_available: bool,
434 pub blocked_by_drift: bool,
436 pub source_stale: bool,
438 pub entries: Vec<DiffEntry>,
440 pub env_additions: Vec<EnvAddition>,
442}
443
444#[derive(Debug, Clone, Serialize, Deserialize)]
446pub struct BackupSnapshotView {
447 pub timestamp: String,
449}
450
451#[derive(Debug, Clone, Serialize, Deserialize)]
453pub struct RevertOutcome {
454 pub service: String,
455 pub timestamp: String,
457 pub files_restored: usize,
458 pub files_deleted: usize,
459}
460
461#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
463#[serde(rename_all = "snake_case")]
464pub enum ServiceState {
465 Running,
466 Stopped,
467 Removed,
469}
470
471#[derive(Debug, Clone, Serialize, Deserialize)]
474pub struct ServiceView {
475 pub name: String,
476 pub state: ServiceState,
477 #[serde(skip_serializing_if = "Option::is_none")]
479 pub url: Option<String>,
480 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
482 pub ports: BTreeMap<String, u16>,
483 #[serde(skip_serializing_if = "Option::is_none")]
485 pub registry: Option<String>,
486 #[serde(skip_serializing_if = "Option::is_none")]
488 pub version: Option<String>,
489 #[serde(default)]
491 pub upgrade_available: bool,
492}
493
494#[derive(Debug, Clone, Serialize, Deserialize)]
498pub struct ApplyOutcome {
499 pub service: ServiceView,
500 pub applied: usize,
501 #[serde(default)]
502 pub destructive: bool,
503}
504
505#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
510#[serde(rename_all = "snake_case")]
511pub enum EnvKindView {
512 Default,
513 Prompted,
514 Required,
515}
516
517#[derive(Debug, Clone, Serialize, Deserialize)]
520pub struct EnvVarView {
521 pub name: String,
522 pub kind: EnvKindView,
523 #[serde(skip_serializing_if = "Option::is_none")]
524 pub prompt: Option<String>,
525 pub format: String,
527 pub generated: bool,
529 pub value_empty: bool,
531}
532
533#[derive(Debug, Clone, Serialize, Deserialize)]
535pub struct EnvGroupView {
536 pub name: String,
537 pub prompt: String,
538 pub env: Vec<EnvVarView>,
539}
540
541#[derive(Debug, Clone, Serialize, Deserialize)]
543pub struct ChoiceOptionView {
544 pub name: String,
545 #[serde(skip_serializing_if = "Option::is_none")]
546 pub label: Option<String>,
547 pub env: Vec<EnvVarView>,
548}
549
550#[derive(Debug, Clone, Serialize, Deserialize)]
552pub struct ChoiceView {
553 pub name: String,
554 pub prompt: String,
555 pub default: String,
556 pub options: Vec<ChoiceOptionView>,
557}
558
559#[derive(Debug, Clone, Serialize, Deserialize)]
561pub struct ServiceDefView {
562 pub name: String,
563 pub env: Vec<EnvVarView>,
564 pub env_groups: Vec<EnvGroupView>,
565 pub choices: Vec<ChoiceView>,
566}
567
568#[derive(Debug, Clone, Serialize, Deserialize)]
571pub struct ConfigureView {
572 pub name: String,
573 pub def: ServiceDefView,
574 pub selected_choices: BTreeMap<String, String>,
576 pub enabled_groups: Vec<String>,
578 pub current_env: BTreeMap<String, String>,
580}
581
582#[derive(Debug, Clone, Serialize, Deserialize)]
584pub struct RegistryTestView {
585 pub name: String,
586 pub kind: String,
588 pub services: Vec<String>,
589 pub step_count: usize,
590 pub step_kinds: Vec<String>,
591 pub needs_browser: bool,
592 pub requires_sudo: bool,
593}
594
595#[derive(Debug, Clone, Serialize, Deserialize)]
597pub struct TestRunView {
598 pub name: String,
599 pub passed: bool,
600 pub duration_secs: f64,
601 pub outcome: String,
603 pub events: Vec<TestEventView>,
604}
605
606#[derive(Debug, Clone, Serialize, Deserialize)]
608pub struct TestEventView {
609 pub description: String,
610 pub kind: String,
612 pub passed: bool,
613 pub skipped: bool,
614 pub error: Option<String>,
615 pub duration_secs: f64,
616 pub stdout: String,
617 pub stderr: String,
618}
619
620#[derive(Debug, Clone, Serialize, Deserialize)]
622pub struct TestStateView {
623 pub sandbox_path: String,
624 pub tests: Vec<TestResultEntryView>,
625}
626
627#[derive(Debug, Clone, Serialize, Deserialize)]
629pub struct TestResultEntryView {
630 pub name: String,
631 pub status: String,
632 pub duration_ms: u64,
633 pub timestamp: u64,
634 pub has_playwright: bool,
635}
636
637#[derive(Debug, Clone, Serialize, Deserialize)]
639#[serde(rename_all = "snake_case")]
640pub enum Response {
641 Applied(ApplyOutcome),
643 Service(ServiceView),
645 Services(Vec<ServiceView>),
647 Diff(DiffView),
649 Backups(Vec<BackupSnapshotView>),
651 Revert(RevertOutcome),
653 SearchResults(Vec<SearchHit>),
655 Registries(Vec<RegistryInfo>),
657 Doctor(Vec<DoctorIssue>),
659 Backup(BackupOutcome),
661 Restore(RestoreOutcome),
663 Snapshots(Vec<SnapshotView>),
665 BackupStatus(BackupStatusView),
667 ServiceDef(ServiceDefView),
669 ConfigureView(ConfigureView),
671 Reconcile(ReconcileOutcome),
673 Tests(Vec<RegistryTestView>),
675 TestRun(TestRunView),
677 TestState(TestStateView),
679 Done,
681}
682
683#[derive(Debug, Clone, Serialize, Deserialize)]
686#[serde(rename_all = "snake_case")]
687pub enum Reply {
688 Ok(Response),
689 Error(RpcError),
690}
691
692#[derive(Debug, Clone, Serialize, Deserialize)]
694pub struct RpcError {
695 pub code: ErrorCode,
696 pub message: String,
697}
698
699#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
701#[serde(rename_all = "snake_case")]
702pub enum ErrorCode {
703 BadRequest,
704 NotFound,
705 Conflict,
706 Internal,
707}
708
709impl RpcError {
710 pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
711 RpcError {
712 code,
713 message: message.into(),
714 }
715 }
716}
717
718#[cfg(test)]
719mod tests {
720 use super::*;
721
722 #[test]
723 fn request_maps_to_method_and_params() {
724 let req = Request::Add(AddRequest::new("forgejo"));
725 let v = serde_json::to_value(&req).unwrap();
726 assert_eq!(v["method"], "add");
727 assert_eq!(v["params"]["service"], "forgejo");
728 }
729
730 #[test]
731 fn unit_request_has_no_params() {
732 let v = serde_json::to_value(Request::List).unwrap();
733 assert_eq!(v["method"], "list");
734 assert!(v.get("params").is_none());
735 }
736
737 #[test]
738 fn service_view_round_trips_and_omits_empties() {
739 let view = ServiceView {
740 name: "forgejo".to_string(),
741 state: ServiceState::Running,
742 url: Some("https://forgejo.example.com".to_string()),
743 ports: BTreeMap::new(),
744 registry: None,
745 version: None,
746 upgrade_available: false,
747 };
748 let v = serde_json::to_value(&view).unwrap();
749 assert!(v.get("ports").is_none());
750 assert_eq!(v["state"], "running");
751 let back: ServiceView = serde_json::from_value(v).unwrap();
752 assert_eq!(back.name, "forgejo");
753 assert_eq!(back.state, ServiceState::Running);
754 }
755}