Skip to main content

ryra_core/
ops.rs

1//! Frontend-neutral operation vocabulary.
2//!
3//! Every way of driving ryra (CLI today, HTTP API, and whatever comes
4//! later) expresses state changes as an [`Operation`] and plans it
5//! through [`plan`]. The request types are plain serde data: required
6//! fields are required by construction, optional knobs are `Option` or
7//! defaulted, and mutually exclusive choices are enums, so a frontend
8//! cannot build an invalid request and cannot silently support less
9//! than the vocabulary (exhaustive `match` breaks the build when a
10//! variant is added).
11//!
12//! Frontends keep their sugar (interactive prompts, auto-installing
13//! authelia/inbucket, batching): sugar resolves user input *into* these
14//! requests. Business rules live here, never in a frontend.
15
16use std::collections::{BTreeMap, BTreeSet};
17use std::path::{Path, PathBuf};
18
19use serde::{Deserialize, Serialize};
20
21use crate::capability::Capability;
22use crate::error::{Error, Result};
23use crate::registry::resolve::ServiceRef;
24use crate::registry::service_def::AuthKind;
25use crate::{
26    AddResult, AddServiceParams, AuthChoice, Exposure, Lifecycle, PlanMode, RemoveMode,
27    RemoveResult, Step, config,
28};
29
30/// How the service should be reachable. The frontends resolve fuzzier
31/// intent (prompts, `--tailscale` tailnet lookup) into one of these.
32#[derive(Debug, Clone, Default, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum ExposureRequest {
35    /// `http://127.0.0.1:<port>` only. If the service requires HTTPS and
36    /// Caddy is installed, planning auto-promotes to a `*.internal` URL
37    /// (the same non-interactive default the CLI uses).
38    #[default]
39    Loopback,
40    /// A concrete URL; classified by hostname into Internal / Public.
41    Url(String),
42    /// A pre-derived `*.ts.net` URL. Deriving it needs the host's
43    /// tailnet identity, which is frontend territory (sudo, tailscale
44    /// CLI), so it arrives here already resolved.
45    Tailscale(String),
46}
47
48/// Whether (and how) to wire the service to the auth provider.
49#[derive(Debug, Clone, Default, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum AuthRequested {
52    #[default]
53    No,
54    /// Use the service's first declared auth kind (the `--auth` rule).
55    Yes,
56    /// A specific kind, e.g. chosen at an interactive prompt.
57    Kind(AuthKind),
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct AddRequest {
62    /// Registry ref: "forgejo", "acme/forgejo", or a local project path.
63    pub service: String,
64    #[serde(default)]
65    pub exposure: ExposureRequest,
66    #[serde(default)]
67    pub auth: AuthRequested,
68    /// `None` = wire SMTP iff a provider is configured (the CLI's
69    /// non-interactive default). `Some(true)` errors loudly when no
70    /// provider exists instead of silently skipping.
71    #[serde(default)]
72    pub smtp: Option<bool>,
73    #[serde(default)]
74    pub backup: bool,
75    #[serde(default)]
76    pub env: BTreeMap<String, String>,
77    #[serde(default)]
78    pub enable_groups: BTreeSet<String>,
79    /// `[[choice]]` selections (`choice name -> option name`). Choices left
80    /// out fall back to their declared `default`.
81    #[serde(default)]
82    pub choose: BTreeMap<String, String>,
83}
84
85impl AddRequest {
86    /// The simplest install: loopback, no integrations.
87    pub fn new(service: impl Into<String>) -> Self {
88        AddRequest {
89            service: service.into(),
90            exposure: ExposureRequest::default(),
91            auth: AuthRequested::default(),
92            smtp: None,
93            backup: false,
94            env: BTreeMap::new(),
95            enable_groups: BTreeSet::new(),
96            choose: BTreeMap::new(),
97        }
98    }
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct RemoveRequest {
103    pub service: String,
104    #[serde(default)]
105    pub mode: RemoveMode,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct LifecycleRequest {
110    pub service: String,
111    pub action: Lifecycle,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct UpgradeRequest {
116    pub service: String,
117    /// Re-render even when the diff is empty (native services rebuild
118    /// from source regardless).
119    #[serde(default)]
120    pub force: bool,
121}
122
123/// Re-render an installed service with a changed integration set. The
124/// change set is core's [`crate::configure::Overrides`]: `None` fields
125/// stay untouched, provided fields are the new truth.
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct ConfigureRequest {
128    pub service: String,
129    pub changes: crate::configure::Overrides,
130}
131
132/// The complete state-changing vocabulary. Wire frontends can carry
133/// this enum directly; the CLI constructs the inner requests from
134/// flags and prompts.
135#[derive(Debug, Clone, Serialize, Deserialize)]
136#[serde(tag = "op", rename_all = "snake_case")]
137pub enum Operation {
138    Add(AddRequest),
139    Remove(RemoveRequest),
140    Lifecycle(LifecycleRequest),
141    Upgrade(UpgradeRequest),
142    Configure(ConfigureRequest),
143    BackupRun(BackupRunRequest),
144}
145
146/// Frontend-supplied capabilities and plan mechanics. Everything here
147/// is either a system probe core refuses to own (`port_in_use`) or
148/// internal plumbing for retries/upgrades; none of it is user intent.
149pub struct PlanContext<'a> {
150    /// `+ Sync` so plans can be held across awaits inside async (Send)
151    /// handlers; the CLI's plain fn pointer satisfies it for free.
152    pub port_in_use: &'a (dyn Fn(u16) -> bool + Sync),
153    /// Already-resolved (ref, repo dir) when the frontend resolved the
154    /// registry earlier (the CLI does, once per batch). `None` resolves
155    /// from `AddRequest::service`.
156    pub resolved: Option<(&'a ServiceRef, &'a Path)>,
157    /// Secrets minted during an interactive prompt phase, reused so the
158    /// values the user saw match what gets written.
159    pub pre_built_ctx: Option<BTreeMap<String, String>>,
160    /// Pin port assignments (upgrade re-renders).
161    pub port_overrides: BTreeMap<String, u16>,
162    pub mode: PlanMode,
163    /// ACME mode for the reverse proxy's own install. Lives here rather
164    /// than in [`AddRequest`] until `AcmeMode` grows serde support;
165    /// only the CLI exposes it today.
166    pub acme: Option<&'a crate::caddy::AcmeMode>,
167}
168
169impl<'a> PlanContext<'a> {
170    pub fn new(port_in_use: &'a (dyn Fn(u16) -> bool + Sync)) -> Self {
171        PlanContext {
172            port_in_use,
173            resolved: None,
174            pre_built_ctx: None,
175            port_overrides: BTreeMap::new(),
176            mode: PlanMode::Add,
177            acme: None,
178        }
179    }
180}
181
182/// A planned add, carrying everything a frontend needs to record and
183/// execute it without re-deriving anything.
184pub struct PlannedAdd {
185    /// Plain service name (ref resolved).
186    pub service: String,
187    pub result: AddResult,
188    pub exposure: Exposure,
189    pub auth_kind: Option<AuthKind>,
190    pub registry_name: String,
191    pub repo_dir: PathBuf,
192    /// Informational decisions made during planning (e.g. auto-derived
193    /// URL). Frontends surface these; silence would hide behavior.
194    pub notes: Vec<String>,
195}
196
197impl PlannedAdd {
198    /// Record the install as pending before executing steps, so a
199    /// failed run is visible to cleanup. Same data in every frontend.
200    pub fn record_pending(&self) -> Result<()> {
201        crate::record_pending(crate::RecordPendingParams {
202            service_name: &self.service,
203            auth_kind: self.auth_kind.clone(),
204            registry_name: &self.registry_name,
205            allocated_ports: &self.result.allocated_ports,
206            repo_dir: &self.repo_dir,
207            exposure: &self.exposure,
208        })
209    }
210}
211
212/// The planned outcome of any [`Operation`]. Execution stays with the
213/// frontend (it owns the Step executor).
214pub enum Planned {
215    Add(Box<PlannedAdd>),
216    Remove(RemoveResult),
217    Lifecycle(Vec<Step>),
218    Upgrade(Box<crate::upgrade::UpgradeResult>),
219    Configure(Box<crate::configure::ConfigureResult>),
220    BackupRun(Box<crate::backup::BackupRunPlan>),
221}
222
223/// Plan one operation. The single entry point shared by all frontends.
224pub async fn plan(op: &Operation, ctx: PlanContext<'_>) -> Result<Planned> {
225    match op {
226        Operation::Add(req) => Ok(Planned::Add(Box::new(plan_add(req, ctx).await?))),
227        Operation::Remove(req) => Ok(Planned::Remove(plan_remove(req)?)),
228        Operation::Lifecycle(req) => Ok(Planned::Lifecycle(plan_lifecycle(req)?)),
229        Operation::Upgrade(req) => Ok(Planned::Upgrade(Box::new(plan_upgrade(req).await?))),
230        Operation::Configure(req) => Ok(Planned::Configure(Box::new(plan_configure(req).await?))),
231        Operation::BackupRun(req) => Ok(Planned::BackupRun(Box::new(plan_backup_run(req).await?))),
232    }
233}
234
235pub async fn plan_upgrade(req: &UpgradeRequest) -> Result<crate::upgrade::UpgradeResult> {
236    crate::upgrade::upgrade_service(&req.service, req.force).await
237}
238
239pub async fn plan_configure(req: &ConfigureRequest) -> Result<crate::configure::ConfigureResult> {
240    crate::configure::configure_service(&req.service, &req.changes).await
241}
242
243pub async fn plan_add(req: &AddRequest, ctx: PlanContext<'_>) -> Result<PlannedAdd> {
244    let mut notes = Vec::new();
245
246    // Resolve the registry ref unless the frontend already did.
247    let (service_ref, repo_dir) = match ctx.resolved {
248        Some((r, d)) => (r.clone(), d.to_path_buf()),
249        None => {
250            let r = ServiceRef::parse(&req.service)?;
251            let d = crate::resolve_registry_dir(&r).await?;
252            (r, d)
253        }
254    };
255    let service = service_ref.service_name().to_string();
256    let reg_service = crate::registry::find_service(&repo_dir, &service)?;
257    let paths = config::ConfigPaths::resolve()?;
258    let cfg = config::load_or_default(&paths.config_file)?;
259
260    // Auth: the `--auth` rule (first declared kind), or a specific kind
261    // which must actually be declared. The auth provider itself is the
262    // exception: it isn't a client of itself.
263    let supported = &reg_service.def.integrations.auth;
264    let auth_kind: Option<AuthKind> = match &req.auth {
265        AuthRequested::No => None,
266        AuthRequested::Yes => match supported.first() {
267            Some(kind) => Some(kind.clone()),
268            None if reg_service
269                .def
270                .capabilities
271                .provides
272                .contains(&Capability::OidcProvider) =>
273            {
274                notes.push(format!(
275                    "{service} is the auth provider itself; auth has no effect"
276                ));
277                None
278            }
279            None => return Err(Error::NoOidcSupport(service)),
280        },
281        AuthRequested::Kind(kind) => {
282            if !supported.contains(kind) {
283                return Err(Error::NoOidcSupport(service));
284            }
285            Some(kind.clone())
286        }
287    };
288    if auth_kind.is_some() && cfg.auth.is_none() {
289        return Err(Error::AuthNotConfigured);
290    }
291
292    // SMTP: explicit request must not silently degrade; the default
293    // wires mail exactly when a provider exists.
294    let enable_smtp = req.smtp.unwrap_or(cfg.smtp.is_some());
295    if enable_smtp && cfg.smtp.is_none() {
296        return Err(Error::ConfigValidation(format!(
297            "SMTP requested for '{service}' but no SMTP provider is configured \
298             (add inbucket, or configure SMTP first)"
299        )));
300    }
301
302    // Exposure: concrete requests pass through classification; Loopback
303    // on an HTTPS-requiring service auto-promotes through Caddy when
304    // possible (the CLI's non-interactive default) and errors loudly
305    // when it can't.
306    let requested_url = match &req.exposure {
307        ExposureRequest::Url(u) => Some(u.as_str()),
308        _ => None,
309    };
310    let needs_https = reg_service
311        .def
312        .service
313        .https
314        .needs_https(auth_kind.is_some(), requested_url);
315    let exposure = match &req.exposure {
316        ExposureRequest::Url(u) => Exposure::from_url(u),
317        ExposureRequest::Tailscale(u) => Exposure::Tailscale { url: u.clone() },
318        ExposureRequest::Loopback if needs_https => {
319            if crate::is_service_installed("caddy") {
320                let https_port = crate::well_known::caddy_https_port(&cfg);
321                let url = format!(
322                    "https://{service}.{}:{https_port}",
323                    config::schema::CADDY_LOCAL_DOMAIN
324                );
325                notes.push(format!("{service} requires HTTPS; exposing at {url}"));
326                Exposure::from_url(&url)
327            } else {
328                return Err(Error::ConfigValidation(format!(
329                    "service '{service}' requires HTTPS but no exposure was given: \
330                     pass a URL or tailscale exposure, or add caddy first"
331                )));
332            }
333        }
334        ExposureRequest::Loopback => Exposure::Loopback,
335    };
336
337    let auth_choice = match &auth_kind {
338        Some(kind) => AuthChoice::Native(kind.clone()),
339        None => AuthChoice::None,
340    };
341    let result = crate::add_service(AddServiceParams {
342        service_name: &service,
343        exposure: &exposure,
344        auth: auth_choice,
345        enable_smtp,
346        enable_backup: req.backup,
347        env_overrides: &req.env,
348        enabled_groups: &req.enable_groups,
349        selected_choices: &req.choose,
350        registry_name: service_ref.registry_name(),
351        repo_dir: &repo_dir,
352        pre_built_ctx: ctx.pre_built_ctx,
353        port_in_use: ctx.port_in_use,
354        acme_mode: ctx.acme,
355        mode: ctx.mode,
356        port_overrides: &ctx.port_overrides,
357    })?;
358
359    Ok(PlannedAdd {
360        registry_name: service_ref.registry_name().to_string(),
361        service,
362        result,
363        exposure,
364        auth_kind,
365        repo_dir,
366        notes,
367    })
368}
369
370pub fn plan_remove(req: &RemoveRequest) -> Result<RemoveResult> {
371    // Fully installed: the normal teardown.
372    if crate::is_service_installed(&req.service) {
373        return crate::remove_service(&req.service, req.mode);
374    }
375    // Not installed, but an interrupted add (or a preserve-mode remove) left
376    // data behind. With purge, clean that orphan up instead of erroring -- the
377    // same recovery `ryra remove <svc> --purge` performs. Without this the rpc
378    // path dead-ends: the service shows as `stopped`, reinstall refuses with
379    // "leftover state from a prior install", and remove says "not installed".
380    if matches!(req.mode, crate::RemoveMode::Purge)
381        && let Some(svc) = crate::data::enumerate_service(&req.service)?
382    {
383        return Ok(RemoveResult {
384            steps: crate::orphan_purge_steps(&svc),
385            service_name: req.service.clone(),
386            url: None,
387        });
388    }
389    // Genuinely absent (or a preserve-mode orphan, which has nothing to tear
390    // down): keep the original not-installed error.
391    crate::remove_service(&req.service, req.mode)
392}
393
394pub fn plan_lifecycle(req: &LifecycleRequest) -> Result<Vec<Step>> {
395    crate::lifecycle_steps(&req.service, req.action)
396}
397
398#[derive(Debug, Clone, Serialize, Deserialize)]
399pub struct BackupRunRequest {
400    pub service: String,
401}
402
403/// Plan a backup of one service: resolves the install's registry dir and
404/// the configured repository. Execution is
405/// [`crate::backup::execute_backup_run`].
406pub async fn plan_backup_run(req: &BackupRunRequest) -> Result<crate::backup::BackupRunPlan> {
407    let paths = config::ConfigPaths::resolve()?;
408    let cfg = config::load_or_default(&paths.config_file)?;
409    let installed = crate::list_installed()?
410        .into_iter()
411        .find(|s| s.name == req.service)
412        .ok_or_else(|| Error::ServiceNotInstalled(req.service.clone()))?;
413    let service_ref = crate::service_ref_from_installed(&installed);
414    let repo_dir = crate::resolve_registry_dir(&service_ref).await?;
415    crate::backup::plan_backup_run(&req.service, &cfg, &repo_dir)
416}