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}
75
76impl AddRequest {
77    /// The simplest install: loopback, no integrations.
78    pub fn new(service: impl Into<String>) -> Self {
79        AddRequest {
80            service: service.into(),
81            exposure: ExposureRequest::default(),
82            auth: AuthRequested::default(),
83            smtp: None,
84            backup: false,
85            env: BTreeMap::new(),
86            enable_groups: BTreeSet::new(),
87            choose: BTreeMap::new(),
88        }
89    }
90}
91
92/// How much to remove.
93#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "snake_case")]
95pub enum RemoveMode {
96    /// Stop + remove quadlets/config but keep data dirs and volumes (orphan).
97    #[default]
98    Preserve,
99    /// Also delete data subdirs and podman named volumes.
100    Purge,
101}
102
103/// Remove a service.
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct RemoveRequest {
106    pub service: String,
107    #[serde(default)]
108    pub mode: RemoveMode,
109}
110
111/// Start or stop an installed service.
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(rename_all = "snake_case")]
114pub enum Lifecycle {
115    Start,
116    Stop,
117}
118
119/// Start/stop a service (and its sidecars).
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct LifecycleRequest {
122    pub service: String,
123    pub action: Lifecycle,
124}
125
126/// Upgrade a service to the registry's current version.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct UpgradeRequest {
129    pub service: String,
130    /// Re-render even when the diff is empty.
131    #[serde(default)]
132    pub force: bool,
133}
134
135/// An exposure transition for `configure`. `Loopback` means "no public route".
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(rename_all = "snake_case")]
138pub enum ExposureChange {
139    Url(String),
140    Tailscale(String),
141    Loopback,
142}
143
144/// The integration change-set for `configure`. `None`/empty fields leave the
145/// current state untouched; provided fields are the new truth.
146#[derive(Debug, Clone, Default, Serialize, Deserialize)]
147#[serde(default)]
148pub struct Overrides {
149    pub exposure: Option<ExposureChange>,
150    pub smtp: Option<bool>,
151    pub backup: Option<bool>,
152    pub auth: Option<bool>,
153    pub enable_groups: BTreeSet<String>,
154    pub disable_groups: BTreeSet<String>,
155    pub choose: BTreeMap<String, String>,
156    pub env_overrides: BTreeMap<String, String>,
157    /// Re-register the OIDC client even when auth is already on and the URL is
158    /// unchanged (repairs a provider/consumer desync).
159    pub reassert_auth: bool,
160}
161
162/// Re-render an installed service with a changed integration set.
163#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct ConfigureRequest {
165    pub service: String,
166    pub changes: Overrides,
167}
168
169/// One request to the agent. Adjacently tagged so it maps straight onto a
170/// JSON-RPC `method` + `params`: `{"method":"add","params":{...}}`,
171/// `{"method":"list"}`.
172#[derive(Debug, Clone, Serialize, Deserialize)]
173#[serde(tag = "method", content = "params", rename_all = "snake_case")]
174pub enum Request {
175    /// Install and start a service.
176    Add(AddRequest),
177    /// Remove a service (optionally purging its data).
178    Remove(RemoveRequest),
179    /// Re-render an installed service with a changed integration set.
180    Configure(ConfigureRequest),
181    /// Start or stop an installed service.
182    Lifecycle(LifecycleRequest),
183    /// Upgrade an installed service to the registry's current version.
184    Upgrade(UpgradeRequest),
185    /// List every service (installed + orphan) with live status.
186    List,
187    /// One service's current view.
188    Get { service: String },
189    /// What an upgrade would change for a service (read-only).
190    Diff { service: String },
191    /// The pre-upgrade snapshots available to revert to, newest first.
192    Backups { service: String },
193    /// Restore a service from a pre-upgrade snapshot (latest if `at` is None).
194    Revert {
195        service: String,
196        #[serde(default)]
197        at: Option<String>,
198    },
199    /// Search a registry for installable services (default registry if unset).
200    Search {
201        #[serde(default)]
202        query: Option<String>,
203        #[serde(default)]
204        registry: Option<String>,
205    },
206    /// List the configured registries.
207    Registries,
208    /// Add a custom registry.
209    AddRegistry { name: String, url: String },
210    /// Remove a custom registry.
211    RemoveRegistry { name: String },
212    /// Run the diagnostics ryra-doctor runs.
213    Doctor,
214    /// Take a backup snapshot of a (backup-enabled) service.
215    Backup { service: String },
216    /// Restore a service's data from a restic snapshot ("latest" for newest).
217    Restore { service: String, snapshot: String },
218    /// List a service's restic data snapshots, newest first (`ryra backup list`).
219    Snapshots { service: String },
220    /// The effective backup configuration + enrolled services
221    /// (`ryra backup status`).
222    BackupStatus,
223    /// Point backups at a backend: init the restic repo and persist `[backup]`
224    /// (`ryra backup configure`). `password` is the restic key; when absent the
225    /// engine reuses the existing key or generates a fresh one.
226    ConfigureBackup {
227        backend: BackupBackendSpec,
228        #[serde(default)]
229        password: Option<String>,
230    },
231    /// Opt a service in or out of backups.
232    SetBackupEnrolled { service: String, enabled: bool },
233    /// The installable env/group/choice schema for a registry service
234    /// (default registry if `registry` is unset).
235    ServiceDef {
236        service: String,
237        #[serde(default)]
238        registry: Option<String>,
239    },
240    /// The configure view (schema + current selections + `.env`) for an
241    /// installed service.
242    ConfigureView { service: String },
243    /// Propagate the current global config into installed services
244    /// (`ryra configure --apply`). Empty `services` = every installed service
245    /// whose env would change; `dry_run` previews without writing/restarting.
246    Reconcile {
247        #[serde(default)]
248        services: Vec<String>,
249        #[serde(default)]
250        dry_run: bool,
251    },
252}
253
254/// The result of a backup run.
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct BackupOutcome {
257    pub service: String,
258    /// Paths included in the snapshot.
259    pub paths: usize,
260}
261
262/// The result of a restore.
263#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct RestoreOutcome {
265    pub service: String,
266    /// The snapshot restored ("latest" when none was specified).
267    pub snapshot: String,
268}
269
270/// Where backups are stored, as a client describes one when configuring.
271#[derive(Debug, Clone, Serialize, Deserialize)]
272#[serde(rename_all = "snake_case")]
273pub enum BackupBackendSpec {
274    /// A local restic repo path (no off-box protection; rarely what you want).
275    Local { path: String },
276    /// Any S3-compatible object store (MinIO, AWS S3, B2, R2, Wasabi).
277    S3 {
278        endpoint: String,
279        bucket: String,
280        access_key_id: String,
281        secret_access_key: String,
282        #[serde(default)]
283        prefix: Option<String>,
284    },
285}
286
287/// One restic data snapshot (`ryra backup list`).
288#[derive(Debug, Clone, Serialize, Deserialize)]
289pub struct SnapshotView {
290    /// Short restic snapshot id; pass back as the restore snapshot.
291    pub id: String,
292    /// RFC3339 timestamp the snapshot was taken.
293    pub time: String,
294    /// Restic tags (e.g. `service:foo`, `manifest_sha:...`).
295    pub tags: Vec<String>,
296}
297
298/// The effective backup configuration plus enrolled services
299/// (`ryra backup status`).
300#[derive(Debug, Clone, Serialize, Deserialize)]
301pub struct BackupStatusView {
302    /// `[backup]` is configured (env-seeded, CLI, or manual).
303    pub configured: bool,
304    /// Human label for the backend, e.g. "S3: my-bucket (...)". None when unset.
305    #[serde(skip_serializing_if = "Option::is_none")]
306    pub backend_label: Option<String>,
307    /// Services enrolled in backups (`metadata.backup_enabled`).
308    pub enrolled: Vec<String>,
309}
310
311/// One env key a reconcile would change in a service's `.env`.
312#[derive(Debug, Clone, Serialize, Deserialize)]
313pub struct EnvKeyChangeView {
314    pub key: String,
315    /// On-disk value, or `None` when the key isn't present yet.
316    pub from: Option<String>,
317    pub to: String,
318    /// True when the key name looks sensitive (a client masks it for display).
319    pub secret: bool,
320}
321
322/// What a reconcile would (or did) do to one installed service.
323#[derive(Debug, Clone, Serialize, Deserialize)]
324pub struct ReconcilePlanView {
325    pub service: String,
326    pub changes: Vec<EnvKeyChangeView>,
327}
328
329/// The outcome of propagating the global config into installed services.
330#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct ReconcileOutcome {
332    /// Affected services and their env diffs (the preview, or what was applied).
333    pub plans: Vec<ReconcilePlanView>,
334    /// How many services were updated and restarted (0 on a dry run).
335    pub applied: usize,
336}
337
338/// One installable service from a registry search.
339#[derive(Debug, Clone, Serialize, Deserialize)]
340pub struct SearchHit {
341    pub name: String,
342    pub description: String,
343    pub installed: bool,
344    /// Integrations the service supports (e.g. "oidc", "smtp").
345    pub supports: Vec<String>,
346}
347
348/// A configured registry.
349#[derive(Debug, Clone, Serialize, Deserialize)]
350pub struct RegistryInfo {
351    pub name: String,
352    pub url: String,
353    pub service_count: usize,
354}
355
356/// Severity of a doctor finding.
357#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
358#[serde(rename_all = "snake_case")]
359pub enum Severity {
360    /// Blocks installs outright.
361    Blocker,
362    /// Service runs but the user probably wants to fix it.
363    Warning,
364    /// Informational.
365    Info,
366}
367
368/// One diagnostic finding.
369#[derive(Debug, Clone, Serialize, Deserialize)]
370pub struct DoctorIssue {
371    /// Stable machine-readable id for the issue variant.
372    pub code: String,
373    pub severity: Severity,
374    /// Full human-readable message, including the suggested fix (byte-for-byte
375    /// what `ryra doctor` prints).
376    pub message: String,
377    /// The service this issue is scoped to, when service-specific.
378    #[serde(skip_serializing_if = "Option::is_none")]
379    pub service: Option<String>,
380}
381
382/// How one file differs between the registry render and disk.
383#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
384#[serde(rename_all = "snake_case")]
385pub enum DiffKind {
386    Unchanged,
387    Modified,
388    /// Hand-edited; blocks a plain upgrade without force.
389    Drift,
390    Added,
391    Removed,
392}
393
394/// One changed file in a [`DiffView`].
395#[derive(Debug, Clone, Serialize, Deserialize)]
396pub struct DiffEntry {
397    pub path: String,
398    pub kind: DiffKind,
399}
400
401/// An env var the registry expects that the install is missing.
402#[derive(Debug, Clone, Serialize, Deserialize)]
403pub struct EnvAddition {
404    pub key: String,
405    /// Registry env kind (default / prompted / required), as a string.
406    pub kind: String,
407    #[serde(skip_serializing_if = "Option::is_none")]
408    pub prompt: Option<String>,
409}
410
411/// What an upgrade would change for a service.
412#[derive(Debug, Clone, Serialize, Deserialize)]
413pub struct DiffView {
414    pub service: String,
415    /// Anything (file or env or stale source) would change on upgrade.
416    pub upgrade_available: bool,
417    /// Hand-edited files would block a plain upgrade (needs force).
418    pub blocked_by_drift: bool,
419    /// Native source changed since the process started (rebuild would ship it).
420    pub source_stale: bool,
421    /// Per-file changes; omits unchanged files.
422    pub entries: Vec<DiffEntry>,
423    /// Env vars the registry expects but the `.env` is missing.
424    pub env_additions: Vec<EnvAddition>,
425}
426
427/// One restorable pre-upgrade snapshot.
428#[derive(Debug, Clone, Serialize, Deserialize)]
429pub struct BackupSnapshotView {
430    /// `YYYY-MM-DDTHH-MM-SSZ`; pass back as `at` to revert to exactly this one.
431    pub timestamp: String,
432}
433
434/// The result of a revert.
435#[derive(Debug, Clone, Serialize, Deserialize)]
436pub struct RevertOutcome {
437    pub service: String,
438    /// The snapshot timestamp restored.
439    pub timestamp: String,
440    pub files_restored: usize,
441    pub files_deleted: usize,
442}
443
444/// Live run state of a service.
445#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
446#[serde(rename_all = "snake_case")]
447pub enum ServiceState {
448    Running,
449    Stopped,
450    /// Removed, but its data is preserved on disk.
451    Removed,
452}
453
454/// A service as seen over the wire: the stable, serde projection of an on-disk
455/// installed service plus its live status.
456#[derive(Debug, Clone, Serialize, Deserialize)]
457pub struct ServiceView {
458    pub name: String,
459    pub state: ServiceState,
460    /// The URL a user reaches the service at, if it has one.
461    #[serde(skip_serializing_if = "Option::is_none")]
462    pub url: Option<String>,
463    /// Allocated host ports (`port_name -> host_port`).
464    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
465    pub ports: BTreeMap<String, u16>,
466    /// Registry the service came from.
467    #[serde(skip_serializing_if = "Option::is_none")]
468    pub registry: Option<String>,
469    /// Installed version.
470    #[serde(skip_serializing_if = "Option::is_none")]
471    pub version: Option<String>,
472    /// A newer version is available in the registry.
473    #[serde(default)]
474    pub upgrade_available: bool,
475}
476
477/// The outcome of a mutating operation: the affected service's fresh view plus
478/// what the apply did. `applied` is the number of steps/changes executed (0 =
479/// nothing to do); `destructive` is true when the change deletes data.
480#[derive(Debug, Clone, Serialize, Deserialize)]
481pub struct ApplyOutcome {
482    pub service: ServiceView,
483    pub applied: usize,
484    #[serde(default)]
485    pub destructive: bool,
486}
487
488// ---- Service-definition views (the install / configure forms) -------------
489
490/// How a registry env var is treated: a `default` value, a `prompted` one the
491/// user may override, or a `required` one they must supply.
492#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
493#[serde(rename_all = "snake_case")]
494pub enum EnvKindView {
495    Default,
496    Prompted,
497    Required,
498}
499
500/// One env var as a form renders it: enough to label it, decide whether it
501/// needs input, and show whether the value is auto-generated.
502#[derive(Debug, Clone, Serialize, Deserialize)]
503pub struct EnvVarView {
504    pub name: String,
505    pub kind: EnvKindView,
506    #[serde(skip_serializing_if = "Option::is_none")]
507    pub prompt: Option<String>,
508    /// Value format: "string", "hex", "base64", "base64_url", "uuid", "jwt_hs256".
509    pub format: String,
510    /// The value comes from a `{{secret.*}}` template, so it's auto-generated.
511    pub generated: bool,
512    /// The declared value is empty (a `prompted` var with no default needs input).
513    pub value_empty: bool,
514}
515
516/// An optional, named group of env vars, enabled together.
517#[derive(Debug, Clone, Serialize, Deserialize)]
518pub struct EnvGroupView {
519    pub name: String,
520    pub prompt: String,
521    pub env: Vec<EnvVarView>,
522}
523
524/// One alternative within a [`ChoiceView`].
525#[derive(Debug, Clone, Serialize, Deserialize)]
526pub struct ChoiceOptionView {
527    pub name: String,
528    #[serde(skip_serializing_if = "Option::is_none")]
529    pub label: Option<String>,
530    pub env: Vec<EnvVarView>,
531}
532
533/// A single-select `[[choice]]`: pick exactly one option.
534#[derive(Debug, Clone, Serialize, Deserialize)]
535pub struct ChoiceView {
536    pub name: String,
537    pub prompt: String,
538    pub default: String,
539    pub options: Vec<ChoiceOptionView>,
540}
541
542/// A service definition's installable schema, as the install picker renders it.
543#[derive(Debug, Clone, Serialize, Deserialize)]
544pub struct ServiceDefView {
545    pub name: String,
546    pub env: Vec<EnvVarView>,
547    pub env_groups: Vec<EnvGroupView>,
548    pub choices: Vec<ChoiceView>,
549}
550
551/// The configure view for an installed service: its rendered schema plus the
552/// selections and `.env` values currently on disk, so a form can pre-fill.
553#[derive(Debug, Clone, Serialize, Deserialize)]
554pub struct ConfigureView {
555    pub name: String,
556    pub def: ServiceDefView,
557    /// Currently selected option per `[[choice]]` (`choice -> option`).
558    pub selected_choices: BTreeMap<String, String>,
559    /// Currently enabled optional groups.
560    pub enabled_groups: Vec<String>,
561    /// Current `.env` values, so prompted/required fields show what's set.
562    pub current_env: BTreeMap<String, String>,
563}
564
565/// The payload of a successful response.
566#[derive(Debug, Clone, Serialize, Deserialize)]
567#[serde(rename_all = "snake_case")]
568pub enum Response {
569    /// `add` / `configure` / `lifecycle` / `upgrade`.
570    Applied(ApplyOutcome),
571    /// `get`.
572    Service(ServiceView),
573    /// `list`.
574    Services(Vec<ServiceView>),
575    /// `diff`.
576    Diff(DiffView),
577    /// `backups`.
578    Backups(Vec<BackupSnapshotView>),
579    /// `revert`.
580    Revert(RevertOutcome),
581    /// `search`.
582    SearchResults(Vec<SearchHit>),
583    /// `registries`.
584    Registries(Vec<RegistryInfo>),
585    /// `doctor`.
586    Doctor(Vec<DoctorIssue>),
587    /// `backup`.
588    Backup(BackupOutcome),
589    /// `restore`.
590    Restore(RestoreOutcome),
591    /// `snapshots`.
592    Snapshots(Vec<SnapshotView>),
593    /// `backup_status`.
594    BackupStatus(BackupStatusView),
595    /// `service_def`.
596    ServiceDef(ServiceDefView),
597    /// `configure_view`.
598    ConfigureView(ConfigureView),
599    /// `reconcile`.
600    Reconcile(ReconcileOutcome),
601    /// `remove` / `add_registry` / `remove_registry`.
602    Done,
603}
604
605/// What `ryra rpc` writes to stdout: exactly one of these per request, then it
606/// exits.
607#[derive(Debug, Clone, Serialize, Deserialize)]
608#[serde(rename_all = "snake_case")]
609pub enum Reply {
610    Ok(Response),
611    Error(RpcError),
612}
613
614/// A structured error, mappable to a JSON-RPC error object.
615#[derive(Debug, Clone, Serialize, Deserialize)]
616pub struct RpcError {
617    pub code: ErrorCode,
618    pub message: String,
619}
620
621/// Coarse error categories, so a client can branch without string-matching.
622#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
623#[serde(rename_all = "snake_case")]
624pub enum ErrorCode {
625    BadRequest,
626    NotFound,
627    Conflict,
628    Internal,
629}
630
631impl RpcError {
632    pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
633        RpcError {
634            code,
635            message: message.into(),
636        }
637    }
638}
639
640#[cfg(test)]
641mod tests {
642    use super::*;
643
644    #[test]
645    fn request_maps_to_method_and_params() {
646        let req = Request::Add(AddRequest::new("forgejo"));
647        let v = serde_json::to_value(&req).unwrap();
648        assert_eq!(v["method"], "add");
649        assert_eq!(v["params"]["service"], "forgejo");
650    }
651
652    #[test]
653    fn unit_request_has_no_params() {
654        let v = serde_json::to_value(Request::List).unwrap();
655        assert_eq!(v["method"], "list");
656        assert!(v.get("params").is_none());
657    }
658
659    #[test]
660    fn service_view_round_trips_and_omits_empties() {
661        let view = ServiceView {
662            name: "forgejo".to_string(),
663            state: ServiceState::Running,
664            url: Some("https://forgejo.example.com".to_string()),
665            ports: BTreeMap::new(),
666            registry: None,
667            version: None,
668            upgrade_available: false,
669        };
670        let v = serde_json::to_value(&view).unwrap();
671        assert!(v.get("ports").is_none());
672        assert_eq!(v["state"], "running");
673        let back: ServiceView = serde_json::from_value(v).unwrap();
674        assert_eq!(back.name, "forgejo");
675        assert_eq!(back.state, ServiceState::Running);
676    }
677}