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    /// Skip-setup: proceed even when a `Required` var has no value, leaving it
84    /// blank in `.env` for the operator to fill in afterwards, rather than
85    /// erroring. The CLI sets this from `--no-setup` / the "Skip setup" choice;
86    /// the strict default (`false`) still fails loudly on a missing required.
87    #[serde(default)]
88    pub allow_unset_required: bool,
89}
90
91impl AddRequest {
92    /// The simplest install: loopback, no integrations.
93    pub fn new(service: impl Into<String>) -> Self {
94        AddRequest {
95            service: service.into(),
96            exposure: ExposureRequest::default(),
97            auth: AuthRequested::default(),
98            smtp: None,
99            backup: false,
100            env: BTreeMap::new(),
101            enable_groups: BTreeSet::new(),
102            choose: BTreeMap::new(),
103            allow_unset_required: false,
104        }
105    }
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct RemoveRequest {
110    pub service: String,
111    #[serde(default)]
112    pub mode: RemoveMode,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct LifecycleRequest {
117    pub service: String,
118    pub action: Lifecycle,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct UpgradeRequest {
123    pub service: String,
124    /// Re-render even when the diff is empty (native services rebuild
125    /// from source regardless).
126    #[serde(default)]
127    pub force: bool,
128}
129
130/// Re-render an installed service with a changed integration set. The
131/// change set is core's [`crate::configure::Overrides`]: `None` fields
132/// stay untouched, provided fields are the new truth.
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct ConfigureRequest {
135    pub service: String,
136    pub changes: crate::configure::Overrides,
137}
138
139/// The complete state-changing vocabulary. Wire frontends can carry
140/// this enum directly; the CLI constructs the inner requests from
141/// flags and prompts.
142#[derive(Debug, Clone, Serialize, Deserialize)]
143#[serde(tag = "op", rename_all = "snake_case")]
144pub enum Operation {
145    Add(AddRequest),
146    Remove(RemoveRequest),
147    Lifecycle(LifecycleRequest),
148    Upgrade(UpgradeRequest),
149    Configure(ConfigureRequest),
150    BackupRun(BackupRunRequest),
151}
152
153/// Frontend-supplied capabilities and plan mechanics. Everything here
154/// is either a system probe core refuses to own (`port_in_use`) or
155/// internal plumbing for retries/upgrades; none of it is user intent.
156pub struct PlanContext<'a> {
157    /// `+ Sync` so plans can be held across awaits inside async (Send)
158    /// handlers; the CLI's plain fn pointer satisfies it for free.
159    pub port_in_use: &'a (dyn Fn(u16) -> bool + Sync),
160    /// Already-resolved (ref, repo dir) when the frontend resolved the
161    /// registry earlier (the CLI does, once per batch). `None` resolves
162    /// from `AddRequest::service`.
163    pub resolved: Option<(&'a ServiceRef, &'a Path)>,
164    /// Secrets minted during an interactive prompt phase, reused so the
165    /// values the user saw match what gets written.
166    pub pre_built_ctx: Option<BTreeMap<String, String>>,
167    /// Pin port assignments (upgrade re-renders).
168    pub port_overrides: BTreeMap<String, u16>,
169    pub mode: PlanMode,
170    /// ACME mode for the reverse proxy's own install. Lives here rather
171    /// than in [`AddRequest`] until `AcmeMode` grows serde support;
172    /// only the CLI exposes it today.
173    pub acme: Option<&'a crate::caddy::AcmeMode>,
174    /// Raw contents of the service's existing on-disk `.env`, read by the
175    /// frontend so the planner can merge the render into it (preserving
176    /// operator keys + rotated secrets) instead of overwriting. `None` for a
177    /// fresh install with no file yet. A system read core won't own itself,
178    /// so it rides here alongside `port_in_use`.
179    pub existing_env: Option<String>,
180}
181
182impl<'a> PlanContext<'a> {
183    pub fn new(port_in_use: &'a (dyn Fn(u16) -> bool + Sync)) -> Self {
184        PlanContext {
185            port_in_use,
186            resolved: None,
187            pre_built_ctx: None,
188            port_overrides: BTreeMap::new(),
189            mode: PlanMode::Add,
190            acme: None,
191            existing_env: None,
192        }
193    }
194}
195
196/// A planned add, carrying everything a frontend needs to record and
197/// execute it without re-deriving anything.
198pub struct PlannedAdd {
199    /// Plain service name (ref resolved).
200    pub service: String,
201    pub result: AddResult,
202    pub exposure: Exposure,
203    pub auth_kind: Option<AuthKind>,
204    pub registry_name: String,
205    pub repo_dir: PathBuf,
206    /// Informational decisions made during planning (e.g. auto-derived
207    /// URL). Frontends surface these; silence would hide behavior.
208    pub notes: Vec<String>,
209}
210
211impl PlannedAdd {
212    /// Record the install as pending before executing steps, so a
213    /// failed run is visible to cleanup. Same data in every frontend.
214    pub fn record_pending(&self) -> Result<()> {
215        crate::record_pending(crate::RecordPendingParams {
216            service_name: &self.service,
217            auth_kind: self.auth_kind.clone(),
218            registry_name: &self.registry_name,
219            allocated_ports: &self.result.allocated_ports,
220            repo_dir: &self.repo_dir,
221            exposure: &self.exposure,
222        })
223    }
224}
225
226/// The planned outcome of any [`Operation`]. Execution stays with the
227/// frontend (it owns the Step executor).
228pub enum Planned {
229    Add(Box<PlannedAdd>),
230    Remove(RemoveResult),
231    Lifecycle(Vec<Step>),
232    Upgrade(Box<crate::upgrade::UpgradeResult>),
233    Configure(Box<crate::configure::ConfigureResult>),
234    BackupRun(Box<crate::backup::BackupRunPlan>),
235}
236
237/// Plan one operation. The single entry point shared by all frontends.
238pub async fn plan(op: &Operation, ctx: PlanContext<'_>) -> Result<Planned> {
239    match op {
240        Operation::Add(req) => Ok(Planned::Add(Box::new(plan_add(req, ctx).await?))),
241        Operation::Remove(req) => Ok(Planned::Remove(plan_remove(req)?)),
242        Operation::Lifecycle(req) => Ok(Planned::Lifecycle(plan_lifecycle(req)?)),
243        Operation::Upgrade(req) => Ok(Planned::Upgrade(Box::new(plan_upgrade(req).await?))),
244        Operation::Configure(req) => Ok(Planned::Configure(Box::new(plan_configure(req).await?))),
245        Operation::BackupRun(req) => Ok(Planned::BackupRun(Box::new(plan_backup_run(req).await?))),
246    }
247}
248
249pub async fn plan_upgrade(req: &UpgradeRequest) -> Result<crate::upgrade::UpgradeResult> {
250    crate::upgrade::upgrade_service(&req.service, req.force).await
251}
252
253pub async fn plan_configure(req: &ConfigureRequest) -> Result<crate::configure::ConfigureResult> {
254    crate::configure::configure_service(&req.service, &req.changes).await
255}
256
257pub async fn plan_add(req: &AddRequest, ctx: PlanContext<'_>) -> Result<PlannedAdd> {
258    let mut notes = Vec::new();
259
260    // Resolve the registry ref unless the frontend already did.
261    let (service_ref, repo_dir) = match ctx.resolved {
262        Some((r, d)) => (r.clone(), d.to_path_buf()),
263        None => {
264            let r = ServiceRef::parse(&req.service)?;
265            let d = crate::resolve_registry_dir(&r).await?;
266            (r, d)
267        }
268    };
269    let service = service_ref.service_name().to_string();
270    let reg_service = crate::registry::find_service(&repo_dir, &service)?;
271    let paths = config::ConfigPaths::resolve()?;
272    let cfg = config::load_or_default(&paths.config_file)?;
273
274    // Auth: the `--auth` rule (first declared kind), or a specific kind
275    // which must actually be declared. The auth provider itself is the
276    // exception: it isn't a client of itself.
277    let supported = &reg_service.def.integrations.auth;
278    let auth_kind: Option<AuthKind> = match &req.auth {
279        AuthRequested::No => None,
280        AuthRequested::Yes => match supported.first() {
281            Some(kind) => Some(kind.clone()),
282            None if reg_service
283                .def
284                .capabilities
285                .provides
286                .contains(&Capability::OidcProvider) =>
287            {
288                notes.push(format!(
289                    "{service} is the auth provider itself; auth has no effect"
290                ));
291                None
292            }
293            None => return Err(Error::NoOidcSupport(service)),
294        },
295        AuthRequested::Kind(kind) => {
296            if !supported.contains(kind) {
297                return Err(Error::NoOidcSupport(service));
298            }
299            Some(kind.clone())
300        }
301    };
302    if auth_kind.is_some() && cfg.auth.is_none() {
303        return Err(Error::AuthNotConfigured);
304    }
305
306    // SMTP: explicit request must not silently degrade; the default
307    // wires mail exactly when a provider exists.
308    let enable_smtp = req.smtp.unwrap_or(cfg.smtp.is_some());
309    if enable_smtp && cfg.smtp.is_none() {
310        return Err(Error::ConfigValidation(format!(
311            "SMTP requested for '{service}' but no SMTP provider is configured \
312             (add inbucket, or configure SMTP first)"
313        )));
314    }
315
316    // Exposure: concrete requests pass through classification; Loopback
317    // on an HTTPS-requiring service auto-promotes through Caddy when
318    // possible (the CLI's non-interactive default) and errors loudly
319    // when it can't.
320    let requested_url = match &req.exposure {
321        ExposureRequest::Url(u) => Some(u.as_str()),
322        _ => None,
323    };
324    let needs_https = reg_service
325        .def
326        .service
327        .https
328        .needs_https(auth_kind.is_some(), requested_url);
329    let exposure = match &req.exposure {
330        ExposureRequest::Url(u) => Exposure::from_url(u),
331        ExposureRequest::Tailscale(u) => Exposure::Tailscale { url: u.clone() },
332        ExposureRequest::Loopback if needs_https => {
333            if crate::is_service_installed("caddy") {
334                let https_port = crate::well_known::caddy_https_port(&cfg);
335                let url = format!(
336                    "https://{service}.{}:{https_port}",
337                    config::schema::CADDY_LOCAL_DOMAIN
338                );
339                notes.push(format!("{service} requires HTTPS; exposing at {url}"));
340                Exposure::from_url(&url)
341            } else {
342                return Err(Error::ConfigValidation(format!(
343                    "service '{service}' requires HTTPS but no exposure was given: \
344                     pass a URL or tailscale exposure, or add caddy first"
345                )));
346            }
347        }
348        ExposureRequest::Loopback => Exposure::Loopback,
349    };
350
351    let auth_choice = match &auth_kind {
352        Some(kind) => AuthChoice::Native(kind.clone()),
353        None => AuthChoice::None,
354    };
355    let result = crate::add_service(AddServiceParams {
356        service_name: &service,
357        exposure: &exposure,
358        auth: auth_choice,
359        enable_smtp,
360        enable_backup: req.backup,
361        env_overrides: &req.env,
362        enabled_groups: &req.enable_groups,
363        selected_choices: &req.choose,
364        registry_name: service_ref.registry_name(),
365        repo_dir: &repo_dir,
366        pre_built_ctx: ctx.pre_built_ctx,
367        port_in_use: ctx.port_in_use,
368        acme_mode: ctx.acme,
369        mode: ctx.mode,
370        port_overrides: &ctx.port_overrides,
371        existing_env_file: ctx.existing_env,
372        allow_unset_required: req.allow_unset_required,
373    })?;
374
375    Ok(PlannedAdd {
376        registry_name: service_ref.registry_name().to_string(),
377        service,
378        result,
379        exposure,
380        auth_kind,
381        repo_dir,
382        notes,
383    })
384}
385
386pub fn plan_remove(req: &RemoveRequest) -> Result<RemoveResult> {
387    // Fully installed: the normal teardown.
388    if crate::is_service_installed(&req.service) {
389        return crate::remove_service(&req.service, req.mode);
390    }
391    // Not installed, but an interrupted add (or a preserve-mode remove) left
392    // data behind. With purge, clean that orphan up instead of erroring -- the
393    // same recovery `ryra remove <svc> --purge` performs. Without this the rpc
394    // path dead-ends: the service shows as `stopped`, reinstall refuses with
395    // "leftover state from a prior install", and remove says "not installed".
396    if matches!(req.mode, crate::RemoveMode::Purge)
397        && let Some(svc) = crate::data::enumerate_service(&req.service)?
398    {
399        return Ok(RemoveResult {
400            steps: crate::orphan_purge_steps(&svc),
401            service_name: req.service.clone(),
402            url: None,
403        });
404    }
405    // Genuinely absent (or a preserve-mode orphan, which has nothing to tear
406    // down): keep the original not-installed error.
407    crate::remove_service(&req.service, req.mode)
408}
409
410pub fn plan_lifecycle(req: &LifecycleRequest) -> Result<Vec<Step>> {
411    crate::lifecycle_steps(&req.service, req.action)
412}
413
414#[derive(Debug, Clone, Serialize, Deserialize)]
415pub struct BackupRunRequest {
416    pub service: String,
417    /// Cadence this snapshot belongs to: `daily` | `weekly` | `manual`.
418    /// Defaults to `manual` (the hand-run, never-pruned mode).
419    #[serde(default = "default_backup_mode")]
420    pub mode: String,
421}
422
423fn default_backup_mode() -> String {
424    "manual".to_string()
425}
426
427/// Plan a backup of one service: resolves the install's registry dir and
428/// the configured repository. Execution is
429/// [`crate::backup::execute_backup_run`].
430pub async fn plan_backup_run(req: &BackupRunRequest) -> Result<crate::backup::BackupRunPlan> {
431    let paths = config::ConfigPaths::resolve()?;
432    let cfg = config::load_or_default(&paths.config_file)?;
433    let installed = crate::list_installed()?
434        .into_iter()
435        .find(|s| s.name == req.service)
436        .ok_or_else(|| Error::ServiceNotInstalled(req.service.clone()))?;
437    let service_ref = crate::service_ref_from_installed(&installed);
438    let repo_dir = crate::resolve_registry_dir(&service_ref).await?;
439    crate::backup::plan_backup_run(&req.service, &cfg, &repo_dir, &req.mode)
440}