ryra-core 0.9.5

Core library for ryra: config, registry, and service generation logic
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
//! Frontend-neutral operation vocabulary.
//!
//! Every way of driving ryra (CLI today, HTTP API, and whatever comes
//! later) expresses state changes as an [`Operation`] and plans it
//! through [`plan`]. The request types are plain serde data: required
//! fields are required by construction, optional knobs are `Option` or
//! defaulted, and mutually exclusive choices are enums, so a frontend
//! cannot build an invalid request and cannot silently support less
//! than the vocabulary (exhaustive `match` breaks the build when a
//! variant is added).
//!
//! Frontends keep their sugar (interactive prompts, auto-installing
//! authelia/inbucket, batching): sugar resolves user input *into* these
//! requests. Business rules live here, never in a frontend.

use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

use crate::capability::Capability;
use crate::error::{Error, Result};
use crate::registry::resolve::ServiceRef;
use crate::registry::service_def::AuthKind;
use crate::{
    AddResult, AddServiceParams, AuthChoice, Exposure, Lifecycle, PlanMode, RemoveMode,
    RemoveResult, Step, config,
};

/// How the service should be reachable. The frontends resolve fuzzier
/// intent (prompts, `--tailscale` tailnet lookup) into one of these.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExposureRequest {
    /// `http://127.0.0.1:<port>` only. If the service requires HTTPS and
    /// Caddy is installed, planning auto-promotes to a `*.internal` URL
    /// (the same non-interactive default the CLI uses).
    #[default]
    Loopback,
    /// A concrete URL; classified by hostname into Internal / Public.
    Url(String),
    /// A pre-derived `*.ts.net` URL. Deriving it needs the host's
    /// tailnet identity, which is frontend territory (sudo, tailscale
    /// CLI), so it arrives here already resolved.
    Tailscale(String),
}

/// Whether (and how) to wire the service to the auth provider.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthRequested {
    #[default]
    No,
    /// Use the service's first declared auth kind (the `--auth` rule).
    Yes,
    /// A specific kind, e.g. chosen at an interactive prompt.
    Kind(AuthKind),
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AddRequest {
    /// Registry ref: "forgejo", "acme/forgejo", or a local project path.
    pub service: String,
    #[serde(default)]
    pub exposure: ExposureRequest,
    #[serde(default)]
    pub auth: AuthRequested,
    /// `None` = wire SMTP iff a provider is configured (the CLI's
    /// non-interactive default). `Some(true)` errors loudly when no
    /// provider exists instead of silently skipping.
    #[serde(default)]
    pub smtp: Option<bool>,
    #[serde(default)]
    pub backup: bool,
    #[serde(default)]
    pub env: BTreeMap<String, String>,
    #[serde(default)]
    pub enable_groups: BTreeSet<String>,
    /// `[[choice]]` selections (`choice name -> option name`). Choices left
    /// out fall back to their declared `default`.
    #[serde(default)]
    pub choose: BTreeMap<String, String>,
}

impl AddRequest {
    /// The simplest install: loopback, no integrations.
    pub fn new(service: impl Into<String>) -> Self {
        AddRequest {
            service: service.into(),
            exposure: ExposureRequest::default(),
            auth: AuthRequested::default(),
            smtp: None,
            backup: false,
            env: BTreeMap::new(),
            enable_groups: BTreeSet::new(),
            choose: BTreeMap::new(),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemoveRequest {
    pub service: String,
    #[serde(default)]
    pub mode: RemoveMode,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LifecycleRequest {
    pub service: String,
    pub action: Lifecycle,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpgradeRequest {
    pub service: String,
    /// Re-render even when the diff is empty (native services rebuild
    /// from source regardless).
    #[serde(default)]
    pub force: bool,
}

/// Re-render an installed service with a changed integration set. The
/// change set is core's [`crate::configure::Overrides`]: `None` fields
/// stay untouched, provided fields are the new truth.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigureRequest {
    pub service: String,
    pub changes: crate::configure::Overrides,
}

/// The complete state-changing vocabulary. Wire frontends can carry
/// this enum directly; the CLI constructs the inner requests from
/// flags and prompts.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum Operation {
    Add(AddRequest),
    Remove(RemoveRequest),
    Lifecycle(LifecycleRequest),
    Upgrade(UpgradeRequest),
    Configure(ConfigureRequest),
    BackupRun(BackupRunRequest),
}

/// Frontend-supplied capabilities and plan mechanics. Everything here
/// is either a system probe core refuses to own (`port_in_use`) or
/// internal plumbing for retries/upgrades; none of it is user intent.
pub struct PlanContext<'a> {
    /// `+ Sync` so plans can be held across awaits inside async (Send)
    /// handlers; the CLI's plain fn pointer satisfies it for free.
    pub port_in_use: &'a (dyn Fn(u16) -> bool + Sync),
    /// Already-resolved (ref, repo dir) when the frontend resolved the
    /// registry earlier (the CLI does, once per batch). `None` resolves
    /// from `AddRequest::service`.
    pub resolved: Option<(&'a ServiceRef, &'a Path)>,
    /// Secrets minted during an interactive prompt phase, reused so the
    /// values the user saw match what gets written.
    pub pre_built_ctx: Option<BTreeMap<String, String>>,
    /// Pin port assignments (upgrade re-renders).
    pub port_overrides: BTreeMap<String, u16>,
    pub mode: PlanMode,
    /// ACME mode for the reverse proxy's own install. Lives here rather
    /// than in [`AddRequest`] until `AcmeMode` grows serde support;
    /// only the CLI exposes it today.
    pub acme: Option<&'a crate::caddy::AcmeMode>,
}

impl<'a> PlanContext<'a> {
    pub fn new(port_in_use: &'a (dyn Fn(u16) -> bool + Sync)) -> Self {
        PlanContext {
            port_in_use,
            resolved: None,
            pre_built_ctx: None,
            port_overrides: BTreeMap::new(),
            mode: PlanMode::Add,
            acme: None,
        }
    }
}

/// A planned add, carrying everything a frontend needs to record and
/// execute it without re-deriving anything.
pub struct PlannedAdd {
    /// Plain service name (ref resolved).
    pub service: String,
    pub result: AddResult,
    pub exposure: Exposure,
    pub auth_kind: Option<AuthKind>,
    pub registry_name: String,
    pub repo_dir: PathBuf,
    /// Informational decisions made during planning (e.g. auto-derived
    /// URL). Frontends surface these; silence would hide behavior.
    pub notes: Vec<String>,
}

impl PlannedAdd {
    /// Record the install as pending before executing steps, so a
    /// failed run is visible to cleanup. Same data in every frontend.
    pub fn record_pending(&self) -> Result<()> {
        crate::record_pending(crate::RecordPendingParams {
            service_name: &self.service,
            auth_kind: self.auth_kind.clone(),
            registry_name: &self.registry_name,
            allocated_ports: &self.result.allocated_ports,
            repo_dir: &self.repo_dir,
            exposure: &self.exposure,
        })
    }
}

/// The planned outcome of any [`Operation`]. Execution stays with the
/// frontend (it owns the Step executor).
pub enum Planned {
    Add(Box<PlannedAdd>),
    Remove(RemoveResult),
    Lifecycle(Vec<Step>),
    Upgrade(Box<crate::upgrade::UpgradeResult>),
    Configure(Box<crate::configure::ConfigureResult>),
    BackupRun(Box<crate::backup::BackupRunPlan>),
}

/// Plan one operation. The single entry point shared by all frontends.
pub async fn plan(op: &Operation, ctx: PlanContext<'_>) -> Result<Planned> {
    match op {
        Operation::Add(req) => Ok(Planned::Add(Box::new(plan_add(req, ctx).await?))),
        Operation::Remove(req) => Ok(Planned::Remove(plan_remove(req)?)),
        Operation::Lifecycle(req) => Ok(Planned::Lifecycle(plan_lifecycle(req)?)),
        Operation::Upgrade(req) => Ok(Planned::Upgrade(Box::new(plan_upgrade(req).await?))),
        Operation::Configure(req) => Ok(Planned::Configure(Box::new(plan_configure(req).await?))),
        Operation::BackupRun(req) => Ok(Planned::BackupRun(Box::new(plan_backup_run(req).await?))),
    }
}

pub async fn plan_upgrade(req: &UpgradeRequest) -> Result<crate::upgrade::UpgradeResult> {
    crate::upgrade::upgrade_service(&req.service, req.force).await
}

pub async fn plan_configure(req: &ConfigureRequest) -> Result<crate::configure::ConfigureResult> {
    crate::configure::configure_service(&req.service, &req.changes).await
}

pub async fn plan_add(req: &AddRequest, ctx: PlanContext<'_>) -> Result<PlannedAdd> {
    let mut notes = Vec::new();

    // Resolve the registry ref unless the frontend already did.
    let (service_ref, repo_dir) = match ctx.resolved {
        Some((r, d)) => (r.clone(), d.to_path_buf()),
        None => {
            let r = ServiceRef::parse(&req.service)?;
            let d = crate::resolve_registry_dir(&r).await?;
            (r, d)
        }
    };
    let service = service_ref.service_name().to_string();
    let reg_service = crate::registry::find_service(&repo_dir, &service)?;
    let paths = config::ConfigPaths::resolve()?;
    let cfg = config::load_or_default(&paths.config_file)?;

    // Auth: the `--auth` rule (first declared kind), or a specific kind
    // which must actually be declared. The auth provider itself is the
    // exception: it isn't a client of itself.
    let supported = &reg_service.def.integrations.auth;
    let auth_kind: Option<AuthKind> = match &req.auth {
        AuthRequested::No => None,
        AuthRequested::Yes => match supported.first() {
            Some(kind) => Some(kind.clone()),
            None if reg_service
                .def
                .capabilities
                .provides
                .contains(&Capability::OidcProvider) =>
            {
                notes.push(format!(
                    "{service} is the auth provider itself; auth has no effect"
                ));
                None
            }
            None => return Err(Error::NoOidcSupport(service)),
        },
        AuthRequested::Kind(kind) => {
            if !supported.contains(kind) {
                return Err(Error::NoOidcSupport(service));
            }
            Some(kind.clone())
        }
    };
    if auth_kind.is_some() && cfg.auth.is_none() {
        return Err(Error::AuthNotConfigured);
    }

    // SMTP: explicit request must not silently degrade; the default
    // wires mail exactly when a provider exists.
    let enable_smtp = req.smtp.unwrap_or(cfg.smtp.is_some());
    if enable_smtp && cfg.smtp.is_none() {
        return Err(Error::ConfigValidation(format!(
            "SMTP requested for '{service}' but no SMTP provider is configured \
             (add inbucket, or configure SMTP first)"
        )));
    }

    // Exposure: concrete requests pass through classification; Loopback
    // on an HTTPS-requiring service auto-promotes through Caddy when
    // possible (the CLI's non-interactive default) and errors loudly
    // when it can't.
    let requested_url = match &req.exposure {
        ExposureRequest::Url(u) => Some(u.as_str()),
        _ => None,
    };
    let needs_https = reg_service
        .def
        .service
        .https
        .needs_https(auth_kind.is_some(), requested_url);
    let exposure = match &req.exposure {
        ExposureRequest::Url(u) => Exposure::from_url(u),
        ExposureRequest::Tailscale(u) => Exposure::Tailscale { url: u.clone() },
        ExposureRequest::Loopback if needs_https => {
            if crate::is_service_installed("caddy") {
                let https_port = crate::well_known::caddy_https_port(&cfg);
                let url = format!(
                    "https://{service}.{}:{https_port}",
                    config::schema::CADDY_LOCAL_DOMAIN
                );
                notes.push(format!("{service} requires HTTPS; exposing at {url}"));
                Exposure::from_url(&url)
            } else {
                return Err(Error::ConfigValidation(format!(
                    "service '{service}' requires HTTPS but no exposure was given: \
                     pass a URL or tailscale exposure, or add caddy first"
                )));
            }
        }
        ExposureRequest::Loopback => Exposure::Loopback,
    };

    let auth_choice = match &auth_kind {
        Some(kind) => AuthChoice::Native(kind.clone()),
        None => AuthChoice::None,
    };
    let result = crate::add_service(AddServiceParams {
        service_name: &service,
        exposure: &exposure,
        auth: auth_choice,
        enable_smtp,
        enable_backup: req.backup,
        env_overrides: &req.env,
        enabled_groups: &req.enable_groups,
        selected_choices: &req.choose,
        registry_name: service_ref.registry_name(),
        repo_dir: &repo_dir,
        pre_built_ctx: ctx.pre_built_ctx,
        port_in_use: ctx.port_in_use,
        acme_mode: ctx.acme,
        mode: ctx.mode,
        port_overrides: &ctx.port_overrides,
    })?;

    Ok(PlannedAdd {
        registry_name: service_ref.registry_name().to_string(),
        service,
        result,
        exposure,
        auth_kind,
        repo_dir,
        notes,
    })
}

pub fn plan_remove(req: &RemoveRequest) -> Result<RemoveResult> {
    // Fully installed: the normal teardown.
    if crate::is_service_installed(&req.service) {
        return crate::remove_service(&req.service, req.mode);
    }
    // Not installed, but an interrupted add (or a preserve-mode remove) left
    // data behind. With purge, clean that orphan up instead of erroring -- the
    // same recovery `ryra remove <svc> --purge` performs. Without this the rpc
    // path dead-ends: the service shows as `stopped`, reinstall refuses with
    // "leftover state from a prior install", and remove says "not installed".
    if matches!(req.mode, crate::RemoveMode::Purge)
        && let Some(svc) = crate::data::enumerate_service(&req.service)?
    {
        return Ok(RemoveResult {
            steps: crate::orphan_purge_steps(&svc),
            service_name: req.service.clone(),
            url: None,
        });
    }
    // Genuinely absent (or a preserve-mode orphan, which has nothing to tear
    // down): keep the original not-installed error.
    crate::remove_service(&req.service, req.mode)
}

pub fn plan_lifecycle(req: &LifecycleRequest) -> Result<Vec<Step>> {
    crate::lifecycle_steps(&req.service, req.action)
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackupRunRequest {
    pub service: String,
}

/// Plan a backup of one service: resolves the install's registry dir and
/// the configured repository. Execution is
/// [`crate::backup::execute_backup_run`].
pub async fn plan_backup_run(req: &BackupRunRequest) -> Result<crate::backup::BackupRunPlan> {
    let paths = config::ConfigPaths::resolve()?;
    let cfg = config::load_or_default(&paths.config_file)?;
    let installed = crate::list_installed()?
        .into_iter()
        .find(|s| s.name == req.service)
        .ok_or_else(|| Error::ServiceNotInstalled(req.service.clone()))?;
    let service_ref = crate::service_ref_from_installed(&installed);
    let repo_dir = crate::resolve_registry_dir(&service_ref).await?;
    crate::backup::plan_backup_run(&req.service, &cfg, &repo_dir)
}