Skip to main content

ryra_protocol/
lib.rs

1//! The typed wire protocol for driving ryra over rpc.
2//!
3//! This crate is the contract, and *only* the contract: pure serde data types,
4//! no dependency on `ryra-core` (the engine). Any client - ryra-api, a control
5//! plane, a third-party tool - can speak it without compiling the engine, which
6//! is what makes ryra-api movable off the box later (it talks to the box's
7//! `ryra rpc` over a transport, depending only on these types).
8//!
9//! The `ryra` binary owns the engine: it deserializes a [`Request`], converts
10//! the protocol-native request payloads into `ryra_core::ops` types, runs them,
11//! and serializes a [`Reply`]. The request payloads here mirror the ops request
12//! structs by shape (not by import), so the engine's internal types stay
13//! engine-private.
14
15use std::collections::{BTreeMap, BTreeSet};
16
17use serde::{Deserialize, Serialize};
18
19// ---- Request payloads (protocol-native; the engine converts to ops::*) ----
20
21/// How a service should be exposed when installed.
22#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum ExposureRequest {
25    #[default]
26    Loopback,
27    /// A concrete URL, classified by hostname into internal/public.
28    Url(String),
29    /// A pre-derived `*.ts.net` URL (the caller resolved the tailnet identity).
30    Tailscale(String),
31}
32
33/// The kind of auth a service can be wired to.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum AuthKind {
37    Oidc,
38}
39
40/// Whether (and how) to wire a service to the auth provider.
41#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum AuthRequested {
44    #[default]
45    No,
46    /// The service's first declared auth kind (the `--auth` rule).
47    Yes,
48    /// A specific kind.
49    Kind(AuthKind),
50}
51
52/// Install and start a service.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct AddRequest {
55    /// Registry ref ("forgejo", "acme/forgejo") or a local project path.
56    pub service: String,
57    #[serde(default)]
58    pub exposure: ExposureRequest,
59    #[serde(default)]
60    pub auth: AuthRequested,
61    /// `None` = wire SMTP iff a provider is configured; `Some(true)` errors
62    /// when none exists rather than silently skipping.
63    #[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    /// `[[choice]]` selections (`choice -> option`); unset choices use defaults.
72    #[serde(default)]
73    pub choose: BTreeMap<String, String>,
74    /// Skip-setup: install even when a `Required` var has no value (left blank
75    /// in `.env` for the operator to fill in later) rather than erroring.
76    #[serde(default)]
77    pub allow_unset_required: bool,
78}
79
80impl AddRequest {
81    /// The simplest install: loopback, no integrations.
82    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/// How much to remove.
98#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(rename_all = "snake_case")]
100pub enum RemoveMode {
101    /// Stop + remove quadlets/config but keep data dirs and volumes (orphan).
102    #[default]
103    Preserve,
104    /// Also delete data subdirs and podman named volumes.
105    Purge,
106}
107
108/// Remove a service.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct RemoveRequest {
111    pub service: String,
112    #[serde(default)]
113    pub mode: RemoveMode,
114}
115
116/// Start or stop an installed service.
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(rename_all = "snake_case")]
119pub enum Lifecycle {
120    Start,
121    Stop,
122}
123
124/// Start/stop a service (and its sidecars).
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct LifecycleRequest {
127    pub service: String,
128    pub action: Lifecycle,
129}
130
131/// Upgrade a service to the registry's current version.
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct UpgradeRequest {
134    pub service: String,
135    /// Re-render even when the diff is empty.
136    #[serde(default)]
137    pub force: bool,
138}
139
140/// An exposure transition for `configure`. `Loopback` means "no public route".
141#[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/// The integration change-set for `configure`. `None`/empty fields leave the
150/// current state untouched; provided fields are the new truth.
151#[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    /// Re-register the OIDC client even when auth is already on and the URL is
163    /// unchanged (repairs a provider/consumer desync).
164    pub reassert_auth: bool,
165}
166
167/// Re-render an installed service with a changed integration set.
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct ConfigureRequest {
170    pub service: String,
171    pub changes: Overrides,
172}
173
174/// One request to the agent. Adjacently tagged so it maps straight onto a
175/// JSON-RPC `method` + `params`: `{"method":"add","params":{...}}`,
176/// `{"method":"list"}`.
177#[derive(Debug, Clone, Serialize, Deserialize)]
178#[serde(tag = "method", content = "params", rename_all = "snake_case")]
179pub enum Request {
180    /// Install and start a service.
181    Add(AddRequest),
182    /// Remove a service (optionally purging its data).
183    Remove(RemoveRequest),
184    /// Re-render an installed service with a changed integration set.
185    Configure(ConfigureRequest),
186    /// Start or stop an installed service.
187    Lifecycle(LifecycleRequest),
188    /// Upgrade an installed service to the registry's current version.
189    Upgrade(UpgradeRequest),
190    /// List every service (installed + orphan) with live status.
191    List,
192    /// One service's current view.
193    Get { service: String },
194    /// What an upgrade would change for a service (read-only).
195    Diff { service: String },
196    /// The pre-upgrade snapshots available to revert to, newest first.
197    Backups { service: String },
198    /// Restore a service from a pre-upgrade snapshot (latest if `at` is None).
199    Revert {
200        service: String,
201        #[serde(default)]
202        at: Option<String>,
203    },
204    /// Search a registry for installable services (default registry if unset).
205    Search {
206        #[serde(default)]
207        query: Option<String>,
208        #[serde(default)]
209        registry: Option<String>,
210    },
211    /// List the configured registries.
212    Registries,
213    /// Add a custom registry.
214    AddRegistry { name: String, url: String },
215    /// Remove a custom registry.
216    RemoveRegistry { name: String },
217    /// Run the diagnostics ryra-doctor runs.
218    Doctor,
219    /// Take a backup snapshot of a (backup-enabled) service.
220    Backup { service: String },
221    /// Restore a service's data from a restic snapshot ("latest" for newest).
222    Restore { service: String, snapshot: String },
223    /// List a service's restic data snapshots, newest first (`ryra backup list`).
224    Snapshots { service: String },
225    /// The effective backup configuration + enrolled services
226    /// (`ryra backup status`).
227    BackupStatus,
228    /// Point backups at a backend: init the restic repo and persist `[backup]`
229    /// (`ryra backup config`). `password` is the restic key; when absent the
230    /// engine reuses the existing key or generates a fresh one.
231    ConfigureBackup {
232        backend: BackupBackendSpec,
233        #[serde(default)]
234        password: Option<String>,
235    },
236    /// Opt a service in or out of backups.
237    SetBackupEnrolled { service: String, enabled: bool },
238    /// The installable env/group/choice schema for a registry service
239    /// (default registry if `registry` is unset).
240    ServiceDef {
241        service: String,
242        #[serde(default)]
243        registry: Option<String>,
244    },
245    /// The configure view (schema + current selections + `.env`) for an
246    /// installed service.
247    ConfigureView { service: String },
248    /// Propagate the current global config into installed services
249    /// (`ryra config --apply`). Empty `services` = every installed service
250    /// whose env would change; `dry_run` previews without writing/restarting.
251    Reconcile {
252        #[serde(default)]
253        services: Vec<String>,
254        #[serde(default)]
255        dry_run: bool,
256    },
257    /// Discover the registry's test suites (`ryra test search`).
258    ListTests,
259    /// Run one registry test by name on the host (`ryra test <name>`).
260    RunTest { name: String },
261    /// Local test sandbox state: installed services + last results
262    /// (`ryra test list`).
263    TestState,
264    /// Delete stored results for one test, or all tests when `name` is None
265    /// (`ryra test remove`).
266    RemoveTestResults {
267        #[serde(default)]
268        name: Option<String>,
269    },
270}
271
272/// The result of a backup run.
273#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct BackupOutcome {
275    pub service: String,
276    /// Paths included in the snapshot.
277    pub paths: usize,
278}
279
280/// The result of a restore.
281#[derive(Debug, Clone, Serialize, Deserialize)]
282pub struct RestoreOutcome {
283    pub service: String,
284    /// The snapshot restored ("latest" when none was specified).
285    pub snapshot: String,
286}
287
288/// Where backups are stored, as a client describes one when configuring.
289#[derive(Debug, Clone, Serialize, Deserialize)]
290#[serde(rename_all = "snake_case")]
291pub enum BackupBackendSpec {
292    /// A local restic repo path (no off-box protection; rarely what you want).
293    Local { path: String },
294    /// Any S3-compatible object store (MinIO, AWS S3, B2, R2, Wasabi).
295    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    /// Ryra-managed: the box holds no storage keys; it vends short-lived,
304    /// account-scoped S3 credentials per backup run. Requires an active managed
305    /// backup plan (configuring without one fails at credential-vend time).
306    Managed,
307}
308
309/// One restic data snapshot (`ryra backup list`).
310#[derive(Debug, Clone, Serialize, Deserialize)]
311pub struct SnapshotView {
312    /// Short restic snapshot id; pass back as the restore snapshot.
313    pub id: String,
314    /// RFC3339 timestamp the snapshot was taken.
315    pub time: String,
316    /// Restic tags (e.g. `service:foo`, `manifest_sha:...`).
317    pub tags: Vec<String>,
318}
319
320/// The effective backup configuration plus enrolled services
321/// (`ryra backup status`).
322#[derive(Debug, Clone, Serialize, Deserialize)]
323pub struct BackupStatusView {
324    /// `[backup]` is configured (env-seeded, CLI, or manual).
325    pub configured: bool,
326    /// Human label for the backend, e.g. "S3: my-bucket (...)". None when unset.
327    #[serde(skip_serializing_if = "Option::is_none")]
328    pub backend_label: Option<String>,
329    /// Services enrolled in backups (`metadata.backup_enabled`).
330    pub enrolled: Vec<String>,
331}
332
333/// One env key a reconcile would change in a service's `.env`.
334#[derive(Debug, Clone, Serialize, Deserialize)]
335pub struct EnvKeyChangeView {
336    pub key: String,
337    /// On-disk value, or `None` when the key isn't present yet.
338    pub from: Option<String>,
339    pub to: String,
340    /// True when the key name looks sensitive (a client masks it for display).
341    pub secret: bool,
342}
343
344/// What a reconcile would (or did) do to one installed service.
345#[derive(Debug, Clone, Serialize, Deserialize)]
346pub struct ReconcilePlanView {
347    pub service: String,
348    pub changes: Vec<EnvKeyChangeView>,
349}
350
351/// The outcome of propagating the global config into installed services.
352#[derive(Debug, Clone, Serialize, Deserialize)]
353pub struct ReconcileOutcome {
354    /// Affected services and their env diffs (the preview, or what was applied).
355    pub plans: Vec<ReconcilePlanView>,
356    /// How many services were updated and restarted (0 on a dry run).
357    pub applied: usize,
358}
359
360/// One installable service from a registry search.
361#[derive(Debug, Clone, Serialize, Deserialize)]
362pub struct SearchHit {
363    pub name: String,
364    pub description: String,
365    pub installed: bool,
366    /// Integrations the service supports (e.g. "oidc", "smtp").
367    pub supports: Vec<String>,
368    /// Recommended RAM in MB from the manifest, when declared. Lets callers
369    /// warn before an install would overcommit the machine's memory.
370    #[serde(default)]
371    pub recommended_ram_mb: Option<u64>,
372}
373
374/// A configured registry.
375#[derive(Debug, Clone, Serialize, Deserialize)]
376pub struct RegistryInfo {
377    pub name: String,
378    pub url: String,
379    pub service_count: usize,
380}
381
382/// Severity of a doctor finding.
383#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
384#[serde(rename_all = "snake_case")]
385pub enum Severity {
386    /// Blocks installs outright.
387    Blocker,
388    /// Service runs but the user probably wants to fix it.
389    Warning,
390    /// Informational.
391    Info,
392}
393
394/// One diagnostic finding.
395#[derive(Debug, Clone, Serialize, Deserialize)]
396pub struct DoctorIssue {
397    /// Stable machine-readable id for the issue variant.
398    pub code: String,
399    pub severity: Severity,
400    /// Full human-readable message, including the suggested fix (byte-for-byte
401    /// what `ryra doctor` prints).
402    pub message: String,
403    /// The service this issue is scoped to, when service-specific.
404    #[serde(skip_serializing_if = "Option::is_none")]
405    pub service: Option<String>,
406}
407
408/// How one file differs between the registry render and disk.
409#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
410#[serde(rename_all = "snake_case")]
411pub enum DiffKind {
412    Unchanged,
413    Modified,
414    /// Hand-edited; blocks a plain upgrade without force.
415    Drift,
416    Added,
417    Removed,
418}
419
420/// One changed file in a [`DiffView`].
421#[derive(Debug, Clone, Serialize, Deserialize)]
422pub struct DiffEntry {
423    pub path: String,
424    pub kind: DiffKind,
425}
426
427/// An env var the registry expects that the install is missing.
428#[derive(Debug, Clone, Serialize, Deserialize)]
429pub struct EnvAddition {
430    pub key: String,
431    /// Registry env kind (default / prompted / required), as a string.
432    pub kind: String,
433    #[serde(skip_serializing_if = "Option::is_none")]
434    pub prompt: Option<String>,
435}
436
437/// What an upgrade would change for a service.
438#[derive(Debug, Clone, Serialize, Deserialize)]
439pub struct DiffView {
440    pub service: String,
441    /// Anything (file or env or stale source) would change on upgrade.
442    pub upgrade_available: bool,
443    /// Hand-edited files would block a plain upgrade (needs force).
444    pub blocked_by_drift: bool,
445    /// Native source changed since the process started (rebuild would ship it).
446    pub source_stale: bool,
447    /// Per-file changes; omits unchanged files.
448    pub entries: Vec<DiffEntry>,
449    /// Env vars the registry expects but the `.env` is missing.
450    pub env_additions: Vec<EnvAddition>,
451}
452
453/// One restorable pre-upgrade snapshot.
454#[derive(Debug, Clone, Serialize, Deserialize)]
455pub struct BackupSnapshotView {
456    /// `YYYY-MM-DDTHH-MM-SSZ`; pass back as `at` to revert to exactly this one.
457    pub timestamp: String,
458}
459
460/// The result of a revert.
461#[derive(Debug, Clone, Serialize, Deserialize)]
462pub struct RevertOutcome {
463    pub service: String,
464    /// The snapshot timestamp restored.
465    pub timestamp: String,
466    pub files_restored: usize,
467    pub files_deleted: usize,
468}
469
470/// Live run state of a service.
471#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
472#[serde(rename_all = "snake_case")]
473pub enum ServiceState {
474    Running,
475    Stopped,
476    /// Install/start is in flight: the unit's start job is still running
477    /// (image pull, container create, health check) so it reports
478    /// `activating`, not yet `active`. A transient state during `ryra add`.
479    Installing,
480    /// Removed, but its data is preserved on disk.
481    Removed,
482}
483
484/// A service as seen over the wire: the stable, serde projection of an on-disk
485/// installed service plus its live status.
486#[derive(Debug, Clone, Serialize, Deserialize)]
487pub struct ServiceView {
488    pub name: String,
489    pub state: ServiceState,
490    /// The URL a user reaches the service at, if it has one.
491    #[serde(skip_serializing_if = "Option::is_none")]
492    pub url: Option<String>,
493    /// Allocated host ports (`port_name -> host_port`).
494    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
495    pub ports: BTreeMap<String, u16>,
496    /// Registry the service came from.
497    #[serde(skip_serializing_if = "Option::is_none")]
498    pub registry: Option<String>,
499    /// Installed version.
500    #[serde(skip_serializing_if = "Option::is_none")]
501    pub version: Option<String>,
502    /// A newer version is available in the registry.
503    #[serde(default)]
504    pub upgrade_available: bool,
505}
506
507/// The outcome of a mutating operation: the affected service's fresh view plus
508/// what the apply did. `applied` is the number of steps/changes executed (0 =
509/// nothing to do); `destructive` is true when the change deletes data.
510#[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// ---- Service-definition views (the install / configure forms) -------------
519
520/// How a registry env var is treated: a `default` value, a `prompted` one the
521/// user may override, or a `required` one they must supply.
522#[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/// One env var as a form renders it: enough to label it, decide whether it
531/// needs input, and show whether the value is auto-generated.
532#[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    /// Value format: "string", "hex", "base64", "base64_url", "uuid", "jwt_hs256".
539    pub format: String,
540    /// The value comes from a `{{secret.*}}` template, so it's auto-generated.
541    pub generated: bool,
542    /// The declared value is empty (a `prompted` var with no default needs input).
543    pub value_empty: bool,
544}
545
546/// An optional, named group of env vars, enabled together.
547#[derive(Debug, Clone, Serialize, Deserialize)]
548pub struct EnvGroupView {
549    pub name: String,
550    pub prompt: String,
551    pub env: Vec<EnvVarView>,
552}
553
554/// One alternative within a [`ChoiceView`].
555#[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/// A single-select `[[choice]]`: pick exactly one option.
564#[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/// A service definition's installable schema, as the install picker renders it.
573#[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/// The configure view for an installed service: its rendered schema plus the
582/// selections and `.env` values currently on disk, so a form can pre-fill.
583#[derive(Debug, Clone, Serialize, Deserialize)]
584pub struct ConfigureView {
585    pub name: String,
586    pub def: ServiceDefView,
587    /// Currently selected option per `[[choice]]` (`choice -> option`).
588    pub selected_choices: BTreeMap<String, String>,
589    /// Currently enabled optional groups.
590    pub enabled_groups: Vec<String>,
591    /// Current `.env` values, so prompted/required fields show what's set.
592    pub current_env: BTreeMap<String, String>,
593}
594
595/// One discoverable registry test (`ryra test search`).
596#[derive(Debug, Clone, Serialize, Deserialize)]
597pub struct RegistryTestView {
598    pub name: String,
599    /// `"simple"` (setup then assert) or `"lifecycle"` (interleaved steps).
600    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/// The outcome of running one test (`ryra test <name>`).
609#[derive(Debug, Clone, Serialize, Deserialize)]
610pub struct TestRunView {
611    pub name: String,
612    pub passed: bool,
613    pub duration_secs: f64,
614    /// `"passed"` / `"skipped"` / a failure message.
615    pub outcome: String,
616    pub events: Vec<TestEventView>,
617}
618
619/// One step/assertion within a test run.
620#[derive(Debug, Clone, Serialize, Deserialize)]
621pub struct TestEventView {
622    pub description: String,
623    /// `"step"` or `"assertion"`.
624    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/// Local test sandbox state: where it lives + the last stored results.
634#[derive(Debug, Clone, Serialize, Deserialize)]
635pub struct TestStateView {
636    pub sandbox_path: String,
637    pub tests: Vec<TestResultEntryView>,
638}
639
640/// One stored test result (from a prior run).
641#[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/// The payload of a successful response.
651#[derive(Debug, Clone, Serialize, Deserialize)]
652#[serde(rename_all = "snake_case")]
653pub enum Response {
654    /// `add` / `configure` / `lifecycle` / `upgrade`.
655    Applied(ApplyOutcome),
656    /// `get`.
657    Service(ServiceView),
658    /// `list`.
659    Services(Vec<ServiceView>),
660    /// `diff`.
661    Diff(DiffView),
662    /// `backups`.
663    Backups(Vec<BackupSnapshotView>),
664    /// `revert`.
665    Revert(RevertOutcome),
666    /// `search`.
667    SearchResults(Vec<SearchHit>),
668    /// `registries`.
669    Registries(Vec<RegistryInfo>),
670    /// `doctor`.
671    Doctor(Vec<DoctorIssue>),
672    /// `backup`.
673    Backup(BackupOutcome),
674    /// `restore`.
675    Restore(RestoreOutcome),
676    /// `snapshots`.
677    Snapshots(Vec<SnapshotView>),
678    /// `backup_status`.
679    BackupStatus(BackupStatusView),
680    /// `service_def`.
681    ServiceDef(ServiceDefView),
682    /// `configure_view`.
683    ConfigureView(ConfigureView),
684    /// `reconcile`.
685    Reconcile(ReconcileOutcome),
686    /// `list_tests`.
687    Tests(Vec<RegistryTestView>),
688    /// `run_test`.
689    TestRun(TestRunView),
690    /// `test_state`.
691    TestState(TestStateView),
692    /// `remove` / `add_registry` / `remove_registry` / `remove_test_results`.
693    Done,
694}
695
696/// What `ryra rpc` writes to stdout: exactly one of these per request, then it
697/// exits.
698#[derive(Debug, Clone, Serialize, Deserialize)]
699#[serde(rename_all = "snake_case")]
700pub enum Reply {
701    Ok(Response),
702    Error(RpcError),
703}
704
705/// A structured error, mappable to a JSON-RPC error object.
706#[derive(Debug, Clone, Serialize, Deserialize)]
707pub struct RpcError {
708    pub code: ErrorCode,
709    pub message: String,
710}
711
712/// Coarse error categories, so a client can branch without string-matching.
713#[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}