use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExposureRequest {
#[default]
Loopback,
Url(String),
Tailscale(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthKind {
Oidc,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthRequested {
#[default]
No,
Yes,
Kind(AuthKind),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AddRequest {
pub service: String,
#[serde(default)]
pub exposure: ExposureRequest,
#[serde(default)]
pub auth: AuthRequested,
#[serde(default)]
pub smtp: Option<bool>,
#[serde(default)]
pub backup: bool,
#[serde(default)]
pub env: BTreeMap<String, String>,
#[serde(default)]
pub enable_groups: BTreeSet<String>,
#[serde(default)]
pub choose: BTreeMap<String, String>,
}
impl AddRequest {
pub fn new(service: impl Into<String>) -> Self {
AddRequest {
service: service.into(),
exposure: ExposureRequest::default(),
auth: AuthRequested::default(),
smtp: None,
backup: false,
env: BTreeMap::new(),
enable_groups: BTreeSet::new(),
choose: BTreeMap::new(),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RemoveMode {
#[default]
Preserve,
Purge,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemoveRequest {
pub service: String,
#[serde(default)]
pub mode: RemoveMode,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Lifecycle {
Start,
Stop,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LifecycleRequest {
pub service: String,
pub action: Lifecycle,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpgradeRequest {
pub service: String,
#[serde(default)]
pub force: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExposureChange {
Url(String),
Tailscale(String),
Loopback,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Overrides {
pub exposure: Option<ExposureChange>,
pub smtp: Option<bool>,
pub backup: Option<bool>,
pub auth: Option<bool>,
pub enable_groups: BTreeSet<String>,
pub disable_groups: BTreeSet<String>,
pub choose: BTreeMap<String, String>,
pub env_overrides: BTreeMap<String, String>,
pub reassert_auth: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigureRequest {
pub service: String,
pub changes: Overrides,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "method", content = "params", rename_all = "snake_case")]
pub enum Request {
Add(AddRequest),
Remove(RemoveRequest),
Configure(ConfigureRequest),
Lifecycle(LifecycleRequest),
Upgrade(UpgradeRequest),
List,
Get { service: String },
Diff { service: String },
Backups { service: String },
Revert {
service: String,
#[serde(default)]
at: Option<String>,
},
Search {
#[serde(default)]
query: Option<String>,
#[serde(default)]
registry: Option<String>,
},
Registries,
AddRegistry { name: String, url: String },
RemoveRegistry { name: String },
Doctor,
Backup { service: String },
Restore { service: String, snapshot: String },
Snapshots { service: String },
BackupStatus,
ConfigureBackup {
backend: BackupBackendSpec,
#[serde(default)]
password: Option<String>,
},
SetBackupEnrolled { service: String, enabled: bool },
ServiceDef {
service: String,
#[serde(default)]
registry: Option<String>,
},
ConfigureView { service: String },
Reconcile {
#[serde(default)]
services: Vec<String>,
#[serde(default)]
dry_run: bool,
},
ListTests,
RunTest { name: String },
TestState,
RemoveTestResults {
#[serde(default)]
name: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackupOutcome {
pub service: String,
pub paths: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RestoreOutcome {
pub service: String,
pub snapshot: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BackupBackendSpec {
Local { path: String },
S3 {
endpoint: String,
bucket: String,
access_key_id: String,
secret_access_key: String,
#[serde(default)]
prefix: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotView {
pub id: String,
pub time: String,
pub tags: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackupStatusView {
pub configured: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub backend_label: Option<String>,
pub enrolled: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnvKeyChangeView {
pub key: String,
pub from: Option<String>,
pub to: String,
pub secret: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReconcilePlanView {
pub service: String,
pub changes: Vec<EnvKeyChangeView>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReconcileOutcome {
pub plans: Vec<ReconcilePlanView>,
pub applied: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchHit {
pub name: String,
pub description: String,
pub installed: bool,
pub supports: Vec<String>,
#[serde(default)]
pub recommended_ram_mb: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegistryInfo {
pub name: String,
pub url: String,
pub service_count: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Severity {
Blocker,
Warning,
Info,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DoctorIssue {
pub code: String,
pub severity: Severity,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub service: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DiffKind {
Unchanged,
Modified,
Drift,
Added,
Removed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiffEntry {
pub path: String,
pub kind: DiffKind,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnvAddition {
pub key: String,
pub kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiffView {
pub service: String,
pub upgrade_available: bool,
pub blocked_by_drift: bool,
pub source_stale: bool,
pub entries: Vec<DiffEntry>,
pub env_additions: Vec<EnvAddition>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackupSnapshotView {
pub timestamp: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RevertOutcome {
pub service: String,
pub timestamp: String,
pub files_restored: usize,
pub files_deleted: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ServiceState {
Running,
Stopped,
Removed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceView {
pub name: String,
pub state: ServiceState,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub ports: BTreeMap<String, u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub registry: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(default)]
pub upgrade_available: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApplyOutcome {
pub service: ServiceView,
pub applied: usize,
#[serde(default)]
pub destructive: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EnvKindView {
Default,
Prompted,
Required,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnvVarView {
pub name: String,
pub kind: EnvKindView,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt: Option<String>,
pub format: String,
pub generated: bool,
pub value_empty: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnvGroupView {
pub name: String,
pub prompt: String,
pub env: Vec<EnvVarView>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChoiceOptionView {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
pub env: Vec<EnvVarView>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChoiceView {
pub name: String,
pub prompt: String,
pub default: String,
pub options: Vec<ChoiceOptionView>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceDefView {
pub name: String,
pub env: Vec<EnvVarView>,
pub env_groups: Vec<EnvGroupView>,
pub choices: Vec<ChoiceView>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigureView {
pub name: String,
pub def: ServiceDefView,
pub selected_choices: BTreeMap<String, String>,
pub enabled_groups: Vec<String>,
pub current_env: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegistryTestView {
pub name: String,
pub kind: String,
pub services: Vec<String>,
pub step_count: usize,
pub step_kinds: Vec<String>,
pub needs_browser: bool,
pub requires_sudo: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestRunView {
pub name: String,
pub passed: bool,
pub duration_secs: f64,
pub outcome: String,
pub events: Vec<TestEventView>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestEventView {
pub description: String,
pub kind: String,
pub passed: bool,
pub skipped: bool,
pub error: Option<String>,
pub duration_secs: f64,
pub stdout: String,
pub stderr: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestStateView {
pub sandbox_path: String,
pub tests: Vec<TestResultEntryView>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestResultEntryView {
pub name: String,
pub status: String,
pub duration_ms: u64,
pub timestamp: u64,
pub has_playwright: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Response {
Applied(ApplyOutcome),
Service(ServiceView),
Services(Vec<ServiceView>),
Diff(DiffView),
Backups(Vec<BackupSnapshotView>),
Revert(RevertOutcome),
SearchResults(Vec<SearchHit>),
Registries(Vec<RegistryInfo>),
Doctor(Vec<DoctorIssue>),
Backup(BackupOutcome),
Restore(RestoreOutcome),
Snapshots(Vec<SnapshotView>),
BackupStatus(BackupStatusView),
ServiceDef(ServiceDefView),
ConfigureView(ConfigureView),
Reconcile(ReconcileOutcome),
Tests(Vec<RegistryTestView>),
TestRun(TestRunView),
TestState(TestStateView),
Done,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Reply {
Ok(Response),
Error(RpcError),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RpcError {
pub code: ErrorCode,
pub message: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorCode {
BadRequest,
NotFound,
Conflict,
Internal,
}
impl RpcError {
pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
RpcError {
code,
message: message.into(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_maps_to_method_and_params() {
let req = Request::Add(AddRequest::new("forgejo"));
let v = serde_json::to_value(&req).unwrap();
assert_eq!(v["method"], "add");
assert_eq!(v["params"]["service"], "forgejo");
}
#[test]
fn unit_request_has_no_params() {
let v = serde_json::to_value(Request::List).unwrap();
assert_eq!(v["method"], "list");
assert!(v.get("params").is_none());
}
#[test]
fn service_view_round_trips_and_omits_empties() {
let view = ServiceView {
name: "forgejo".to_string(),
state: ServiceState::Running,
url: Some("https://forgejo.example.com".to_string()),
ports: BTreeMap::new(),
registry: None,
version: None,
upgrade_available: false,
};
let v = serde_json::to_value(&view).unwrap();
assert!(v.get("ports").is_none());
assert_eq!(v["state"], "running");
let back: ServiceView = serde_json::from_value(v).unwrap();
assert_eq!(back.name, "forgejo");
assert_eq!(back.state, ServiceState::Running);
}
}