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