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}