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}