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    /// Store an account token in the box's credentials file -- the structured
239    /// equivalent of `ryra account login --with-token`. The control plane uses
240    /// this to sign a managed box into its account for backups, over rpc rather
241    /// than an ad-hoc SSH file write. The engine owns the path/format/perms.
242    AccountLogin { token: String },
243    /// Prune snapshots to the configured retention ladder (`restic forget`,
244    /// then prune). `None` service = every enrolled service; `dry_run` previews
245    /// what would be removed without deleting. A no-op for a service with no
246    /// retention policy.
247    ForgetBackups {
248        #[serde(default)]
249        service: Option<String>,
250        #[serde(default)]
251        dry_run: bool,
252    },
253    /// Back up enrolled services (empty `services` = every enrolled install) --
254    /// the rpc twin of `ryra backup run`, for control-plane/dashboard parity.
255    RunBackups {
256        #[serde(default)]
257        services: Vec<String>,
258        /// Cadence tag for the snapshots: `daily` | `weekly` | `manual`.
259        /// Defaults to `manual` (hand-run, never pruned).
260        #[serde(default)]
261        mode: Option<String>,
262    },
263    /// Full disaster recovery: restore EVERY service in the repo at `snapshot`
264    /// ("latest" or an id), in dependency order, re-linking + starting them.
265    /// The rpc twin of `ryra backup restore` with no service.
266    RestoreAll { snapshot: String },
267    /// Set the full backup schedule (rpc twin of `ryra backup config`'s schedule
268    /// step + `ryra backup schedule`). Each cadence: `Some` enables it (keep N
269    /// at `HH:MM`), `None` disables it. Installs/removes the daily + weekly
270    /// timers to match. Manual backups are always available and unaffected.
271    SetSchedule {
272        #[serde(default)]
273        daily: Option<ScheduleSpec>,
274        #[serde(default)]
275        weekly: Option<ScheduleSpec>,
276    },
277    /// Permanently delete one snapshot by id (`restic forget <id> --prune`).
278    /// The rpc twin of `ryra backup delete`.
279    DeleteSnapshot { id: String },
280    /// Disconnect backups: clear the `[backup]` config + remove the schedule
281    /// timers. Existing snapshots in the bucket are NOT touched -- reconnecting
282    /// to the same backend + password picks them back up. Twin of
283    /// `ryra backup disconnect`.
284    DisconnectBackup,
285    /// The installable env/group/choice schema for a registry service
286    /// (default registry if `registry` is unset).
287    ServiceDef {
288        service: String,
289        #[serde(default)]
290        registry: Option<String>,
291    },
292    /// The configure view (schema + current selections + `.env`) for an
293    /// installed service.
294    ConfigureView { service: String },
295    /// Propagate the current global config into installed services
296    /// (`ryra config --apply`). Empty `services` = every installed service
297    /// whose env would change; `dry_run` previews without writing/restarting.
298    Reconcile {
299        #[serde(default)]
300        services: Vec<String>,
301        #[serde(default)]
302        dry_run: bool,
303    },
304    /// Discover the registry's test suites (`ryra test search`).
305    ListTests,
306    /// Run one registry test by name on the host (`ryra test <name>`).
307    RunTest { name: String },
308    /// Local test sandbox state: installed services + last results
309    /// (`ryra test list`).
310    TestState,
311    /// Delete stored results for one test, or all tests when `name` is None
312    /// (`ryra test remove`).
313    RemoveTestResults {
314        #[serde(default)]
315        name: Option<String>,
316    },
317}
318
319/// The result of a backup run.
320#[derive(Debug, Clone, Serialize, Deserialize)]
321pub struct BackupOutcome {
322    pub service: String,
323    /// Paths included in the snapshot.
324    pub paths: usize,
325}
326
327/// The result of a restore.
328#[derive(Debug, Clone, Serialize, Deserialize)]
329pub struct RestoreOutcome {
330    pub service: String,
331    /// The snapshot restored ("latest" when none was specified).
332    pub snapshot: String,
333}
334
335/// Where backups are stored, as a client describes one when configuring.
336#[derive(Debug, Clone, Serialize, Deserialize)]
337#[serde(rename_all = "snake_case")]
338pub enum BackupBackendSpec {
339    /// A local restic repo path (no off-box protection; rarely what you want).
340    Local { path: String },
341    /// Any S3-compatible object store (MinIO, AWS S3, B2, R2, Wasabi).
342    S3 {
343        endpoint: String,
344        bucket: String,
345        access_key_id: String,
346        secret_access_key: String,
347        #[serde(default)]
348        prefix: Option<String>,
349    },
350    /// Ryra-managed: the box holds no storage keys; it vends short-lived,
351    /// account-scoped S3 credentials per backup run. Requires an active managed
352    /// backup plan (configuring without one fails at credential-vend time).
353    Managed,
354}
355
356/// One restic data snapshot (`ryra backup list`).
357#[derive(Debug, Clone, Serialize, Deserialize)]
358pub struct SnapshotView {
359    /// Short restic snapshot id; pass back as the restore snapshot.
360    pub id: String,
361    /// RFC3339 timestamp the snapshot was taken.
362    pub time: String,
363    /// Restic tags (e.g. `service:foo`, `manifest_sha:...`).
364    pub tags: Vec<String>,
365}
366
367/// The effective backup configuration plus enrolled services
368/// (`ryra backup status`).
369#[derive(Debug, Clone, Serialize, Deserialize)]
370pub struct BackupStatusView {
371    /// `[backup]` is configured (env-seeded, CLI, or manual).
372    pub configured: bool,
373    /// Human label for the backend, e.g. "S3: my-bucket (...)". None when unset.
374    #[serde(skip_serializing_if = "Option::is_none")]
375    pub backend_label: Option<String>,
376    /// Services enrolled in backups (`metadata.backup_enabled`).
377    pub enrolled: Vec<String>,
378    /// Daily schedule (keep N at HH:MM), if enabled. `None` = no daily backups.
379    #[serde(default, skip_serializing_if = "Option::is_none")]
380    pub daily: Option<ScheduleSpec>,
381    /// Weekly schedule (Sunday), if enabled. `None` = no weekly backups.
382    #[serde(default, skip_serializing_if = "Option::is_none")]
383    pub weekly: Option<ScheduleSpec>,
384}
385
386/// A scheduled backup cadence: keep at most `keep` snapshots of this mode,
387/// run at `at` (24h `HH:MM`; `None` => the 03:00 default). Used both to set
388/// the schedule (`SetSchedule`) and to report it (`BackupStatusView`).
389#[derive(Debug, Clone, Serialize, Deserialize)]
390pub struct ScheduleSpec {
391    pub keep: u32,
392    #[serde(default)]
393    pub at: Option<String>,
394}
395
396/// Per-service result of a retention sweep (`ForgetBackups`).
397#[derive(Debug, Clone, Serialize, Deserialize)]
398pub struct ForgetView {
399    pub service: String,
400    /// Snapshots kept after the sweep.
401    pub kept: u32,
402    /// Snapshots removed (in a dry run, the count that WOULD be removed).
403    pub removed: u32,
404    /// True when this was a preview (`--dry-run`); nothing was deleted.
405    pub dry_run: bool,
406}
407
408/// One env key a reconcile would change in a service's `.env`.
409#[derive(Debug, Clone, Serialize, Deserialize)]
410pub struct EnvKeyChangeView {
411    pub key: String,
412    /// On-disk value, or `None` when the key isn't present yet.
413    pub from: Option<String>,
414    pub to: String,
415    /// True when the key name looks sensitive (a client masks it for display).
416    pub secret: bool,
417}
418
419/// What a reconcile would (or did) do to one installed service.
420#[derive(Debug, Clone, Serialize, Deserialize)]
421pub struct ReconcilePlanView {
422    pub service: String,
423    pub changes: Vec<EnvKeyChangeView>,
424}
425
426/// The outcome of propagating the global config into installed services.
427#[derive(Debug, Clone, Serialize, Deserialize)]
428pub struct ReconcileOutcome {
429    /// Affected services and their env diffs (the preview, or what was applied).
430    pub plans: Vec<ReconcilePlanView>,
431    /// How many services were updated and restarted (0 on a dry run).
432    pub applied: usize,
433}
434
435/// One installable service from a registry search.
436#[derive(Debug, Clone, Serialize, Deserialize)]
437pub struct SearchHit {
438    pub name: String,
439    pub description: String,
440    pub installed: bool,
441    /// Integrations the service supports (e.g. "oidc", "smtp").
442    pub supports: Vec<String>,
443    /// Recommended RAM in MB from the manifest, when declared. Lets callers
444    /// warn before an install would overcommit the machine's memory.
445    #[serde(default)]
446    pub recommended_ram_mb: Option<u64>,
447}
448
449/// A configured registry.
450#[derive(Debug, Clone, Serialize, Deserialize)]
451pub struct RegistryInfo {
452    pub name: String,
453    pub url: String,
454    pub service_count: usize,
455}
456
457/// Severity of a doctor finding.
458#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
459#[serde(rename_all = "snake_case")]
460pub enum Severity {
461    /// Blocks installs outright.
462    Blocker,
463    /// Service runs but the user probably wants to fix it.
464    Warning,
465    /// Informational.
466    Info,
467}
468
469/// One diagnostic finding.
470#[derive(Debug, Clone, Serialize, Deserialize)]
471pub struct DoctorIssue {
472    /// Stable machine-readable id for the issue variant.
473    pub code: String,
474    pub severity: Severity,
475    /// Full human-readable message, including the suggested fix (byte-for-byte
476    /// what `ryra doctor` prints).
477    pub message: String,
478    /// The service this issue is scoped to, when service-specific.
479    #[serde(skip_serializing_if = "Option::is_none")]
480    pub service: Option<String>,
481}
482
483/// How one file differs between the registry render and disk.
484#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
485#[serde(rename_all = "snake_case")]
486pub enum DiffKind {
487    Unchanged,
488    Modified,
489    /// Hand-edited; blocks a plain upgrade without force.
490    Drift,
491    Added,
492    Removed,
493}
494
495/// One changed file in a [`DiffView`].
496#[derive(Debug, Clone, Serialize, Deserialize)]
497pub struct DiffEntry {
498    pub path: String,
499    pub kind: DiffKind,
500}
501
502/// An env var the registry expects that the install is missing.
503#[derive(Debug, Clone, Serialize, Deserialize)]
504pub struct EnvAddition {
505    pub key: String,
506    /// Registry env kind (default / prompted / required), as a string.
507    pub kind: String,
508    #[serde(skip_serializing_if = "Option::is_none")]
509    pub prompt: Option<String>,
510}
511
512/// What an upgrade would change for a service.
513#[derive(Debug, Clone, Serialize, Deserialize)]
514pub struct DiffView {
515    pub service: String,
516    /// Anything (file or env or stale source) would change on upgrade.
517    pub upgrade_available: bool,
518    /// Hand-edited files would block a plain upgrade (needs force).
519    pub blocked_by_drift: bool,
520    /// Native source changed since the process started (rebuild would ship it).
521    pub source_stale: bool,
522    /// Per-file changes; omits unchanged files.
523    pub entries: Vec<DiffEntry>,
524    /// Env vars the registry expects but the `.env` is missing.
525    pub env_additions: Vec<EnvAddition>,
526}
527
528/// One restorable pre-upgrade snapshot.
529#[derive(Debug, Clone, Serialize, Deserialize)]
530pub struct BackupSnapshotView {
531    /// `YYYY-MM-DDTHH-MM-SSZ`; pass back as `at` to revert to exactly this one.
532    pub timestamp: String,
533}
534
535/// The result of a revert.
536#[derive(Debug, Clone, Serialize, Deserialize)]
537pub struct RevertOutcome {
538    pub service: String,
539    /// The snapshot timestamp restored.
540    pub timestamp: String,
541    pub files_restored: usize,
542    pub files_deleted: usize,
543}
544
545/// Live run state of a service.
546#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
547#[serde(rename_all = "snake_case")]
548pub enum ServiceState {
549    Running,
550    Stopped,
551    /// Install/start is in flight: the unit's start job is still running
552    /// (image pull, container create, health check) so it reports
553    /// `activating`, not yet `active`. A transient state during `ryra add`.
554    Installing,
555    /// Removed, but its data is preserved on disk.
556    Removed,
557}
558
559/// A service as seen over the wire: the stable, serde projection of an on-disk
560/// installed service plus its live status.
561#[derive(Debug, Clone, Serialize, Deserialize)]
562pub struct ServiceView {
563    pub name: String,
564    pub state: ServiceState,
565    /// The URL a user reaches the service at, if it has one.
566    #[serde(skip_serializing_if = "Option::is_none")]
567    pub url: Option<String>,
568    /// Allocated host ports (`port_name -> host_port`).
569    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
570    pub ports: BTreeMap<String, u16>,
571    /// Registry the service came from.
572    #[serde(skip_serializing_if = "Option::is_none")]
573    pub registry: Option<String>,
574    /// Installed version.
575    #[serde(skip_serializing_if = "Option::is_none")]
576    pub version: Option<String>,
577    /// A newer version is available in the registry.
578    #[serde(default)]
579    pub upgrade_available: bool,
580}
581
582/// The outcome of a mutating operation: the affected service's fresh view plus
583/// what the apply did. `applied` is the number of steps/changes executed (0 =
584/// nothing to do); `destructive` is true when the change deletes data.
585#[derive(Debug, Clone, Serialize, Deserialize)]
586pub struct ApplyOutcome {
587    pub service: ServiceView,
588    pub applied: usize,
589    #[serde(default)]
590    pub destructive: bool,
591}
592
593// ---- Service-definition views (the install / configure forms) -------------
594
595/// How a registry env var is treated: a `default` value, a `prompted` one the
596/// user may override, or a `required` one they must supply.
597#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
598#[serde(rename_all = "snake_case")]
599pub enum EnvKindView {
600    Default,
601    Prompted,
602    Required,
603}
604
605/// One env var as a form renders it: enough to label it, decide whether it
606/// needs input, and show whether the value is auto-generated.
607#[derive(Debug, Clone, Serialize, Deserialize)]
608pub struct EnvVarView {
609    pub name: String,
610    pub kind: EnvKindView,
611    #[serde(skip_serializing_if = "Option::is_none")]
612    pub prompt: Option<String>,
613    /// Value format: "string", "hex", "base64", "base64_url", "uuid", "jwt_hs256".
614    pub format: String,
615    /// The value comes from a `{{secret.*}}` template, so it's auto-generated.
616    pub generated: bool,
617    /// The declared value is empty (a `prompted` var with no default needs input).
618    pub value_empty: bool,
619}
620
621/// An optional, named group of env vars, enabled together.
622#[derive(Debug, Clone, Serialize, Deserialize)]
623pub struct EnvGroupView {
624    pub name: String,
625    pub prompt: String,
626    pub env: Vec<EnvVarView>,
627}
628
629/// One alternative within a [`ChoiceView`].
630#[derive(Debug, Clone, Serialize, Deserialize)]
631pub struct ChoiceOptionView {
632    pub name: String,
633    #[serde(skip_serializing_if = "Option::is_none")]
634    pub label: Option<String>,
635    pub env: Vec<EnvVarView>,
636}
637
638/// A single-select `[[choice]]`: pick exactly one option.
639#[derive(Debug, Clone, Serialize, Deserialize)]
640pub struct ChoiceView {
641    pub name: String,
642    pub prompt: String,
643    pub default: String,
644    pub options: Vec<ChoiceOptionView>,
645}
646
647/// A service definition's installable schema, as the install picker renders it.
648#[derive(Debug, Clone, Serialize, Deserialize)]
649pub struct ServiceDefView {
650    pub name: String,
651    pub env: Vec<EnvVarView>,
652    pub env_groups: Vec<EnvGroupView>,
653    pub choices: Vec<ChoiceView>,
654}
655
656/// The configure view for an installed service: its rendered schema plus the
657/// selections and `.env` values currently on disk, so a form can pre-fill.
658#[derive(Debug, Clone, Serialize, Deserialize)]
659pub struct ConfigureView {
660    pub name: String,
661    pub def: ServiceDefView,
662    /// Currently selected option per `[[choice]]` (`choice -> option`).
663    pub selected_choices: BTreeMap<String, String>,
664    /// Currently enabled optional groups.
665    pub enabled_groups: Vec<String>,
666    /// Current `.env` values, so prompted/required fields show what's set.
667    pub current_env: BTreeMap<String, String>,
668}
669
670/// One discoverable registry test (`ryra test search`).
671#[derive(Debug, Clone, Serialize, Deserialize)]
672pub struct RegistryTestView {
673    pub name: String,
674    /// `"simple"` (setup then assert) or `"lifecycle"` (interleaved steps).
675    pub kind: String,
676    pub services: Vec<String>,
677    pub step_count: usize,
678    pub step_kinds: Vec<String>,
679    pub needs_browser: bool,
680    pub requires_sudo: bool,
681}
682
683/// The outcome of running one test (`ryra test <name>`).
684#[derive(Debug, Clone, Serialize, Deserialize)]
685pub struct TestRunView {
686    pub name: String,
687    pub passed: bool,
688    pub duration_secs: f64,
689    /// `"passed"` / `"skipped"` / a failure message.
690    pub outcome: String,
691    pub events: Vec<TestEventView>,
692}
693
694/// One step/assertion within a test run.
695#[derive(Debug, Clone, Serialize, Deserialize)]
696pub struct TestEventView {
697    pub description: String,
698    /// `"step"` or `"assertion"`.
699    pub kind: String,
700    pub passed: bool,
701    pub skipped: bool,
702    pub error: Option<String>,
703    pub duration_secs: f64,
704    pub stdout: String,
705    pub stderr: String,
706}
707
708/// Local test sandbox state: where it lives + the last stored results.
709#[derive(Debug, Clone, Serialize, Deserialize)]
710pub struct TestStateView {
711    pub sandbox_path: String,
712    pub tests: Vec<TestResultEntryView>,
713}
714
715/// One stored test result (from a prior run).
716#[derive(Debug, Clone, Serialize, Deserialize)]
717pub struct TestResultEntryView {
718    pub name: String,
719    pub status: String,
720    pub duration_ms: u64,
721    pub timestamp: u64,
722    pub has_playwright: bool,
723}
724
725/// The payload of a successful response.
726#[derive(Debug, Clone, Serialize, Deserialize)]
727#[serde(rename_all = "snake_case")]
728pub enum Response {
729    /// `add` / `configure` / `lifecycle` / `upgrade`.
730    Applied(ApplyOutcome),
731    /// `get`.
732    Service(ServiceView),
733    /// `list`.
734    Services(Vec<ServiceView>),
735    /// `diff`.
736    Diff(DiffView),
737    /// `backups`.
738    Backups(Vec<BackupSnapshotView>),
739    /// `revert`.
740    Revert(RevertOutcome),
741    /// `search`.
742    SearchResults(Vec<SearchHit>),
743    /// `registries`.
744    Registries(Vec<RegistryInfo>),
745    /// `doctor`.
746    Doctor(Vec<DoctorIssue>),
747    /// `backup`.
748    Backup(BackupOutcome),
749    /// `restore`.
750    Restore(RestoreOutcome),
751    /// `snapshots`.
752    Snapshots(Vec<SnapshotView>),
753    /// `backup_status`.
754    BackupStatus(BackupStatusView),
755    /// `forget_backups` — per-service retention sweep results.
756    Forget(Vec<ForgetView>),
757    /// `service_def`.
758    ServiceDef(ServiceDefView),
759    /// `configure_view`.
760    ConfigureView(ConfigureView),
761    /// `reconcile`.
762    Reconcile(ReconcileOutcome),
763    /// `list_tests`.
764    Tests(Vec<RegistryTestView>),
765    /// `run_test`.
766    TestRun(TestRunView),
767    /// `test_state`.
768    TestState(TestStateView),
769    /// `remove` / `add_registry` / `remove_registry` / `remove_test_results`.
770    Done,
771}
772
773/// What `ryra rpc` writes to stdout: exactly one of these per request, then it
774/// exits.
775#[derive(Debug, Clone, Serialize, Deserialize)]
776#[serde(rename_all = "snake_case")]
777pub enum Reply {
778    Ok(Response),
779    Error(RpcError),
780}
781
782/// A structured error, mappable to a JSON-RPC error object.
783#[derive(Debug, Clone, Serialize, Deserialize)]
784pub struct RpcError {
785    pub code: ErrorCode,
786    pub message: String,
787}
788
789/// Coarse error categories, so a client can branch without string-matching.
790#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
791#[serde(rename_all = "snake_case")]
792pub enum ErrorCode {
793    BadRequest,
794    NotFound,
795    Conflict,
796    Internal,
797}
798
799impl RpcError {
800    pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
801        RpcError {
802            code,
803            message: message.into(),
804        }
805    }
806}
807
808#[cfg(test)]
809mod tests {
810    use super::*;
811
812    #[test]
813    fn request_maps_to_method_and_params() {
814        let req = Request::Add(AddRequest::new("forgejo"));
815        let v = serde_json::to_value(&req).unwrap();
816        assert_eq!(v["method"], "add");
817        assert_eq!(v["params"]["service"], "forgejo");
818    }
819
820    #[test]
821    fn unit_request_has_no_params() {
822        let v = serde_json::to_value(Request::List).unwrap();
823        assert_eq!(v["method"], "list");
824        assert!(v.get("params").is_none());
825    }
826
827    #[test]
828    fn service_view_round_trips_and_omits_empties() {
829        let view = ServiceView {
830            name: "forgejo".to_string(),
831            state: ServiceState::Running,
832            url: Some("https://forgejo.example.com".to_string()),
833            ports: BTreeMap::new(),
834            registry: None,
835            version: None,
836            upgrade_available: false,
837        };
838        let v = serde_json::to_value(&view).unwrap();
839        assert!(v.get("ports").is_none());
840        assert_eq!(v["state"], "running");
841        let back: ServiceView = serde_json::from_value(v).unwrap();
842        assert_eq!(back.name, "forgejo");
843        assert_eq!(back.state, ServiceState::Running);
844    }
845}