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