Skip to main content

ryra_core/
lib.rs

1pub mod auth_bridge;
2pub mod authelia;
3pub mod backup;
4pub mod caddy;
5pub mod capability;
6pub mod config;
7pub mod configure;
8pub mod data;
9pub mod error;
10pub mod exposure;
11pub mod generate;
12pub mod manifest;
13pub mod metadata;
14pub mod paths;
15pub mod plan;
16pub mod registry;
17pub mod system;
18pub mod upgrade;
19pub mod well_known;
20
21use std::collections::BTreeMap;
22use std::path::{Path, PathBuf};
23
24use config::ConfigPaths;
25use config::schema::InstalledService;
26use error::{Error, Result};
27
28pub use capability::{
29    Capability, any_installed_provider, find_installed_provider, installed_provides,
30    service_provides,
31};
32pub use configure::{
33    ConfigureChange, ConfigureResult, ExposureChange, Overrides as ConfigureOverrides,
34    configure_service,
35};
36pub use exposure::{
37    Exposure, check_auth_exposure_compat, is_caddy_local_url, is_public_url, is_tailscale_url,
38};
39pub use generate::GeneratedFile;
40pub use manifest::{ManifestEntry, manifest_path};
41pub use metadata::{Metadata, load_metadata};
42pub use paths::{
43    CONFIG_DIR_ENV, DATA_DIR_ENV, DEFAULT_REGISTRY_URL, REGISTRY_DEFAULT, REGISTRY_DIR_ENV,
44    metadata_path, quadlet_dir, service_data_root, service_home, systemd_user_dir,
45};
46pub use plan::{AddResult, RemoveResult, ResetResult, Step, TailscalePort, TrackedEnv, Warning};
47pub use upgrade::{
48    BackupSnapshot, DEFAULT_BACKUP_KEEP, DiffEntry, DiffKind, DiffResult, EnvAddition,
49    RevertResult, UpgradeResult, diff_service, list_backups, prune_backups, revert_service,
50    upgrade_service,
51};
52pub use well_known::WellKnownService;
53
54pub(crate) use paths::home_dir;
55pub(crate) use well_known::caddy_https_port;
56
57/// Resolve the registry directory for a service reference.
58pub async fn resolve_registry_dir(service_ref: &registry::resolve::ServiceRef) -> Result<PathBuf> {
59    let paths = ConfigPaths::resolve()?;
60    paths.ensure_cache_dir()?;
61    let config = config::load_or_default(&paths.config_file)?;
62    registry::resolve::resolve_registry_dir(service_ref, &config, &paths.cache_dir).await
63}
64
65/// Build a ServiceRef from an installed service's stored registry name.
66pub fn service_ref_from_installed(installed: &InstalledService) -> registry::resolve::ServiceRef {
67    if installed.repo.is_empty() || installed.repo == REGISTRY_DEFAULT {
68        registry::resolve::ServiceRef::Default(installed.name.clone())
69    } else {
70        registry::resolve::ServiceRef::Custom {
71            registry: installed.repo.clone(),
72            service: installed.name.clone(),
73        }
74    }
75}
76
77/// When a shared-network provider (caddy or inbucket) is installed, patch
78/// already-installed services' primary quadlets to include `Network=<svc>.network`
79/// if they should reach the new provider. Emits `WriteFile` + `DaemonReload`
80/// + `RestartService` per patched service.
81///
82/// Scope is intentionally narrow: it only adds the network that the newly
83/// installed provider owns. The install-time networking policy for each
84/// patched service's OTHER networks is unchanged.
85fn retroactive_network_joins(
86    new_service: &str,
87    quadlet_path: &std::path::Path,
88    _repo_dir: Option<&std::path::Path>,
89) -> Vec<Step> {
90    let mut steps = Vec::new();
91    // Which join-relevant capability did the new service just become a
92    // provider of? `OidcProvider` doesn't trigger this — auth-aware
93    // services join its network at install time via the auth bridge,
94    // not retroactively.
95    let new_cap = if service_provides(new_service, Capability::ReverseProxy) {
96        Capability::ReverseProxy
97    } else if service_provides(new_service, Capability::SmtpRelay) {
98        Capability::SmtpRelay
99    } else {
100        return steps;
101    };
102
103    let installed = list_installed().unwrap_or_default();
104    for svc in &installed {
105        // Providers (of any capability) don't join themselves to other
106        // providers' networks via this path.
107        if !svc.provides.is_empty() {
108            continue;
109        }
110        let (network_name, should_join) = match new_cap {
111            Capability::ReverseProxy => {
112                // Services with a routed URL want the proxy network.
113                // Tailscale-exposed services route via `tailscale serve`,
114                // not the reverse proxy, so they skip.
115                let wants_proxy = matches!(
116                    svc.exposure,
117                    Exposure::Internal { .. } | Exposure::Public { .. }
118                );
119                (new_service.to_string(), wants_proxy)
120            }
121            Capability::SmtpRelay => {
122                // Any already-installed service whose .env points SMTP at
123                // the relay's hostname needs to reach it.
124                (
125                    new_service.to_string(),
126                    service_uses_smtp_relay(&svc.name, new_service),
127                )
128            }
129            // Unreachable: `new_cap` was selected from the two cases
130            // above. Match exhaustively so a new join-relevant capability
131            // forces a compile error here.
132            Capability::OidcProvider | Capability::ForwardAuthProvider => {
133                continue;
134            }
135        };
136        if !should_join {
137            continue;
138        }
139        // Multi-container services (e.g. zammad with a separate railsserver
140        // that actually sends mail) need the network on every component
141        // container. Patch each `.container` file belonging to this service
142        // and restart each unit so podman recreates the container with the
143        // new network. Restarting only the primary unit doesn't cascade to
144        // subunits — their containers would keep running on the old network.
145        let installed_names_owned: Vec<String> = installed.iter().map(|s| s.name.clone()).collect();
146        let all_service_names: Vec<&str> =
147            installed_names_owned.iter().map(|s| s.as_str()).collect();
148        let marker = format!("Network={network_name}.network");
149        let mut units_to_restart: Vec<String> = Vec::new();
150        let Ok(entries) = std::fs::read_dir(quadlet_path) else {
151            continue;
152        };
153        for entry in entries.flatten() {
154            let path = entry.path();
155            let name = match path.file_name().and_then(|n| n.to_str()) {
156                Some(n) if n.ends_with(".container") => n.to_string(),
157                _ => continue,
158            };
159            if !quadlet_belongs_to(&name, &svc.name, &all_service_names) {
160                continue;
161            }
162            let content = match std::fs::read_to_string(&path) {
163                Ok(c) => c,
164                Err(_) => continue,
165            };
166            if content.contains(&marker) {
167                continue;
168            }
169            // The quadlet dir holds symlinks into service homes — patch the
170            // real file, not the link: atomic_write (correctly) refuses to
171            // overwrite a symlink with a regular file.
172            let real_path = match std::fs::canonicalize(&path) {
173                Ok(p) => p,
174                Err(_) => continue,
175            };
176            let updated =
177                generate::bundle::inject_networks(&content, std::slice::from_ref(&network_name));
178            steps.push(Step::WriteFile(GeneratedFile {
179                path: real_path,
180                content: updated,
181            }));
182            // Unit name is the .container filename minus extension; systemd's
183            // generator turns `foo-bar.container` into `foo-bar.service`.
184            let unit = name.trim_end_matches(".container").to_string();
185            units_to_restart.push(unit);
186        }
187        if !units_to_restart.is_empty() {
188            steps.push(Step::DaemonReload);
189            for unit in units_to_restart {
190                steps.push(Step::RestartService { unit });
191            }
192        }
193    }
194    steps
195}
196
197/// Heuristic: does this service's `.env` point SMTP at the given relay's
198/// container hostname? Matches any line whose value is `<relay>` or
199/// `<relay>:<port>` — covers the common shape
200/// `SOMETHING_SMTP_HOST=<relay>` and variants like
201/// `FORGEJO__mailer__SMTP_ADDR=<relay>`.
202fn service_uses_smtp_relay(service_name: &str, relay_host: &str) -> bool {
203    let env_path = match service_home(service_name) {
204        Ok(h) => h.join(".env"),
205        Err(_) => return false,
206    };
207    let content = match std::fs::read_to_string(&env_path) {
208        Ok(c) => c,
209        Err(_) => return false,
210    };
211    let with_port = format!("{relay_host}:");
212    content.lines().any(|line| {
213        let Some((_, value)) = line.split_once('=') else {
214            return false;
215        };
216        let v = value.trim();
217        v == relay_host || v.starts_with(&with_port)
218    })
219}
220
221/// Determine which extra podman networks a service should join.
222///
223/// Three providers own a shared network:
224/// - `authelia.network` — services with `--auth` join so they can reach the
225///   OIDC provider by container DNS.
226/// - `inbucket.network` — services with SMTP configured join so they can
227///   reach `inbucket:2500` without requiring caddy to be installed.
228/// - `caddy.network` — URL-having services join for reverse-proxy routing;
229///   auth-enabled services join so OIDC discovery goes through caddy's TLS;
230///   inbucket itself joins so its web UI can be reverse-proxied when a URL
231///   is supplied.
232#[allow(clippy::too_many_arguments)]
233fn resolve_extra_networks(
234    service_name: &str,
235    enable_auth: bool,
236    authelia_installed: bool,
237    caddy_installed: bool,
238    inbucket_installed: bool,
239    has_url: bool,
240    has_smtp: bool,
241) -> Vec<String> {
242    let mut networks = Vec::new();
243    if enable_auth && authelia_installed && !WellKnownService::Authelia.matches(service_name) {
244        networks.push(WellKnownService::Authelia.to_string());
245    }
246    // SMTP-using services reach inbucket via its own network — no caddy
247    // dependency. This is symmetric with how auth services reach authelia.
248    let joins_inbucket =
249        has_smtp && inbucket_installed && !WellKnownService::Inbucket.matches(service_name);
250    if joins_inbucket {
251        networks.push(WellKnownService::Inbucket.to_string());
252    }
253    let joins_caddy = (has_url || enable_auth || WellKnownService::Inbucket.matches(service_name))
254        && caddy_installed
255        && !WellKnownService::Caddy.matches(service_name);
256    if joins_caddy && !networks.contains(&WellKnownService::Caddy.to_string()) {
257        networks.push(WellKnownService::Caddy.to_string());
258    }
259    networks
260}
261
262/// Why the planner is running. The render path is shared between fresh
263/// installs and re-renders (`ryra upgrade`); the side-effect steps are
264/// not — re-registering an OIDC client on upgrade would mint a new
265/// `client_id`/`client_secret` against authelia's existing entry, and
266/// patching every other installed service's quadlet (retroactive network
267/// joins) is install-time work. The mode gates those.
268#[derive(Debug, Clone, Copy, PartialEq, Eq)]
269pub enum PlanMode {
270    /// Fresh install. Validate that the service isn't already on disk,
271    /// register OIDC clients, retroactively patch other services to
272    /// join shared networks, set up Tailscale, and start the unit.
273    Add,
274    /// Re-render an installed service to pick up registry changes.
275    /// Skips the validation rejects and the install-time side effects;
276    /// the upgrade caller handles diff/backup/restart.
277    Upgrade,
278}
279
280/// The user's auth decision for one install.
281///
282/// Replaces the `(Option<AuthKind>, bool)` pair that previously travelled
283/// through the planner, where `enable_auth = false` with a `Some` kind was
284/// representable but meaningless. Frontends resolve "--auth on a service
285/// with no native kinds" before constructing this (error, or a no-op note
286/// for the OIDC provider itself), so a choice always round-trips through
287/// `metadata.toml` as `Option<AuthKind>`.
288#[derive(Debug, Clone, PartialEq, Eq)]
289pub enum AuthChoice {
290    /// No auth integration.
291    None,
292    /// Auth with the service's native OIDC integration: OIDC env vars are
293    /// templated and a client is registered with the provider.
294    Native(registry::service_def::AuthKind),
295}
296
297impl AuthChoice {
298    /// True when the user asked for auth. Drives the auth fabric:
299    /// network joins, the auth bridge, the no-native-OIDC validation.
300    pub fn enabled(&self) -> bool {
301        !matches!(self, AuthChoice::None)
302    }
303
304    /// The native OIDC kind, when the service has one. Drives OIDC env
305    /// templating and client registration.
306    pub fn native_kind(&self) -> Option<&registry::service_def::AuthKind> {
307        match self {
308            AuthChoice::Native(kind) => Some(kind),
309            AuthChoice::None => None,
310        }
311    }
312}
313
314/// Inputs to [`add_service`]. One typed request instead of a positional
315/// argument list, so call sites (CLI today, other frontends later) name
316/// what they're asking for and the retry path can't drift out of sync
317/// with the original call.
318pub struct AddServiceParams<'a> {
319    pub service_name: &'a str,
320    pub exposure: &'a Exposure,
321    pub auth: AuthChoice,
322    pub enable_smtp: bool,
323    pub enable_backup: bool,
324    pub env_overrides: &'a BTreeMap<String, String>,
325    pub enabled_groups: &'a std::collections::BTreeSet<String>,
326    pub registry_name: &'a str,
327    pub repo_dir: &'a Path,
328    /// When provided, its secrets and auth credentials are reused instead
329    /// of generating fresh ones. Pass the context from the interactive
330    /// prompt phase so the values the user saw match what gets written.
331    pub pre_built_ctx: Option<BTreeMap<String, String>>,
332    pub port_in_use: &'a dyn Fn(u16) -> bool,
333    pub acme_mode: Option<&'a caddy::AcmeMode>,
334    pub mode: PlanMode,
335    /// Pin specific port assignments by name (e.g. `{"http": 10005}`)
336    /// instead of running the allocator. Used by upgrade so a re-render
337    /// preserves the install's existing host ports — port_in_use would
338    /// say they're taken (the running service holds them) and the
339    /// allocator would skip to the next free one.
340    pub port_overrides: &'a BTreeMap<String, u16>,
341}
342
343/// Add a service: generate config, return steps to execute.
344pub fn add_service(params: AddServiceParams<'_>) -> Result<AddResult> {
345    let AddServiceParams {
346        service_name,
347        exposure,
348        auth,
349        enable_smtp,
350        enable_backup,
351        env_overrides,
352        enabled_groups,
353        registry_name,
354        repo_dir,
355        pre_built_ctx,
356        port_in_use,
357        acme_mode,
358        mode,
359        port_overrides,
360    } = params;
361    // Derived views of the typed inputs, bound once for the many body
362    // sites that only need one facet. Helpers that need the full picture
363    // (env templating, exposure-variant dispatch) take `&Exposure` /
364    // `&AuthChoice` facets directly.
365    let auth_kind: Option<&registry::service_def::AuthKind> = auth.native_kind();
366    let enable_auth: bool = auth.enabled();
367    let url: Option<&str> = exposure.url();
368    let paths = ConfigPaths::resolve()?;
369    let config = config::load_or_default(&paths.config_file)?;
370
371    // Quadlet directory is the source of truth: a marker'd `.container`
372    // means the service is already installed.
373    //
374    // Upgrade explicitly *re-renders* an installed service — those rejects
375    // would block the legitimate path.
376    if mode == PlanMode::Add {
377        if is_service_installed(service_name) {
378            return Err(Error::ServiceAlreadyInstalled(service_name.to_string()));
379        }
380
381        // No config entry, but preserved volumes or a lingering home dir from
382        // `ryra remove <svc>` (default Preserve mode) would make the fresh .env's
383        // generated secrets disagree with what's already baked into the volume —
384        // postgres writes POSTGRES_PASSWORD into pgdata on first init and then
385        // skips reinit, so a new password in .env just restart-loops on auth
386        // failures. Surface the same way as an incomplete install; the CLI's
387        // existing purge-and-retry recovery handles it.
388        if data::enumerate_service(service_name)?.is_some() {
389            return Err(Error::ServiceIncomplete(service_name.to_string()));
390        }
391    }
392
393    let reg_service = registry::find_service(repo_dir, service_name)?;
394
395    // Validate: architecture compatibility
396    if let Some(msg) = reg_service.def.check_architecture() {
397        return Err(Error::UnsupportedArchitecture(msg));
398    }
399
400    // Validate: all required services must be installed
401    let missing_requires: Vec<&str> = reg_service
402        .def
403        .requires
404        .iter()
405        .filter(|r| !is_service_installed(&r.service))
406        .map(|r| r.service.as_str())
407        .collect();
408    if !missing_requires.is_empty() {
409        return Err(Error::MissingRequiredServices {
410            service: service_name.to_string(),
411            missing: missing_requires.iter().map(|s| s.to_string()).collect(),
412        });
413    }
414
415    // If the user chose to enable auth, an auth provider must be configured
416    if auth_kind.is_some() && config.auth.is_none() {
417        return Err(Error::AuthNotConfigured);
418    }
419
420    // --auth requires native OIDC support; forward auth is no longer supported.
421    // The exception is the OIDC provider itself, which doesn't need to act as
422    // a client of itself.
423    if enable_auth
424        && reg_service.def.integrations.auth.is_empty()
425        && !capability::def_provides(&reg_service.def, Capability::OidcProvider)
426    {
427        return Err(Error::NoOidcSupport(service_name.to_string()));
428    }
429
430    // --backup requires the service author to have certified backup
431    // safety. Refusing here means a user typo can't silently produce
432    // an install whose backups would never restore cleanly.
433    if enable_backup && !reg_service.def.integrations.backup {
434        return Err(Error::BackupNotSupported(service_name.to_string()));
435    }
436
437    // Every `--enable <group>` must match a group defined on this service.
438    // Surfacing unknown group names here (vs. silently ignoring them) means
439    // a typo fails fast instead of producing a half-configured service.
440    for g in enabled_groups {
441        if !reg_service.def.env_groups.iter().any(|eg| &eg.name == g) {
442            let known: Vec<String> = reg_service
443                .def
444                .env_groups
445                .iter()
446                .map(|eg| eg.name.clone())
447                .collect();
448            let hint = if known.is_empty() {
449                " (service defines no env_groups)".to_string()
450            } else {
451                format!(" (known: {})", known.join(", "))
452            };
453            return Err(Error::UnknownEnvGroup {
454                service: service_name.to_string(),
455                group: g.clone(),
456                hint,
457            });
458        }
459    }
460
461    // Resolve a host port for every entry in [[ports]]. Each port gets its
462    // own distinct host port — a prior bug allocated one port and gave it
463    // to every entry, so services with multiple [[ports]] (ente-web:
464    // 3000/3002/3003, inbucket: http+smtp) hit `bind: address already in
465    // use` on all but the first.
466    let mut port_warnings: Vec<Warning> = Vec::new();
467    let mut claimed: std::collections::HashSet<u16> = reg_service
468        .def
469        .ports
470        .iter()
471        .filter_map(|p| p.host_port)
472        .collect();
473    let mut resolved_ports: Vec<(String, u16)> = Vec::with_capacity(reg_service.def.ports.len());
474    for p in &reg_service.def.ports {
475        let host = if let Some(pinned) = port_overrides.get(&p.name) {
476            // Upgrade passes the install's existing port here so re-renders
477            // are stable. Trust the caller — port_in_use would say it's
478            // taken (the running service holds it) and the allocator would
479            // pick a different one.
480            *pinned
481        } else if let Some(hp) = p.host_port {
482            hp
483        } else {
484            let privileged = p.container_port < 1024;
485            let claimed_in_service = claimed.contains(&p.container_port);
486            let in_use = port_in_use(p.container_port);
487            if privileged || claimed_in_service || in_use {
488                let allocated = system::port::allocate_port_excluding(&claimed, port_in_use)?;
489                let reason = if privileged {
490                    "port is privileged (requires root)".to_string()
491                } else if claimed_in_service {
492                    format!(
493                        "port {} is already claimed by another port in this service",
494                        p.container_port
495                    )
496                } else {
497                    format!("port {} is already in use", p.container_port)
498                };
499                port_warnings.push(Warning::PortReassigned {
500                    service_name: service_name.to_string(),
501                    port_name: p.name.clone(),
502                    original_port: p.container_port,
503                    assigned_port: allocated,
504                    reason,
505                });
506                allocated
507            } else {
508                p.container_port
509            }
510        };
511        claimed.insert(host);
512        resolved_ports.push((p.name.clone(), host));
513    }
514
515    // Caddy on rootless podman can't bind <1024 by default — service.toml
516    // therefore declares 8080/8443 as the host ports. When the kernel has
517    // been retuned (`sysctl net.ipv4.ip_unprivileged_port_start=80`), we
518    // can listen on 80/443 directly: cleaner URLs, no router NAT
519    // translation needed. Override here so the quadlet's `PublishPort=`
520    // and the stored config record both reflect the real listen port.
521    if WellKnownService::Caddy.matches(service_name)
522        && system::sysctl::rootless_can_bind_low_ports()
523    {
524        for (name, port) in resolved_ports.iter_mut() {
525            match name.as_str() {
526                "http" if *port == 8080 => *port = 80,
527                "https" if *port == 8443 => *port = 443,
528                _ => {}
529            }
530        }
531    }
532
533    // Primary host port drives service.url / service.port templating.
534    // Prefer "http", fall back to the first defined port.
535    let host_port = resolved_ports
536        .iter()
537        .find(|(name, _)| name.eq_ignore_ascii_case("http"))
538        .or_else(|| resolved_ports.first())
539        .map(|(_, p)| *p);
540
541    // Check for port conflicts by probing whether the port is already bound.
542    // For explicitly-set host_port entries, allocate_port_excluding didn't run
543    // so we re-check here.
544    for (_, port) in &resolved_ports {
545        if port_in_use(*port) {
546            return Err(Error::PortConflict { port: *port });
547        }
548    }
549
550    let home_dir = service_home(service_name)?;
551    let quadlet_path = quadlet_dir()?;
552
553    // Authoritative: capability presence in `list_installed` answers
554    // "is there a reverse-proxy / OIDC IdP / SMTP relay / metrics
555    // scraper installed?" without naming any specific provider.
556    let installed_now = list_installed().unwrap_or_default();
557    let authelia_installed =
558        find_installed_provider(&installed_now, Capability::OidcProvider).is_some();
559    let caddy_installed =
560        find_installed_provider(&installed_now, Capability::ReverseProxy).is_some();
561    let inbucket_installed =
562        find_installed_provider(&installed_now, Capability::SmtpRelay).is_some();
563
564    // Build auth-bridge artifacts (CA trust + dynamic /etc/hosts for the
565    // auth provider's domain). Pure — all filesystem writes are
566    // emitted as Step::WriteFile below, not performed here.
567    let auth_bridge = auth_bridge::build(&auth_bridge::AuthBridgeParams {
568        service_name,
569        service_provides: &reg_service.def.capabilities.provides,
570        enable_auth,
571        config: &config,
572        installed: &installed_now,
573        service_data: &home_dir,
574    })?;
575
576    let (extra_volumes, extra_env, extra_exec_start_pre, auth_bridge_steps) = match auth_bridge {
577        Some(b) => (b.volumes, b.env, b.exec_start_pre, b.steps),
578        None => (Vec::new(), BTreeMap::new(), Vec::new(), Vec::new()),
579    };
580
581    let has_smtp = enable_smtp
582        && reg_service.def.integrations.smtp
583        && !reg_service.def.mappings.smtp.is_empty()
584        && config.smtp.is_some();
585    let extra_networks = resolve_extra_networks(
586        service_name,
587        enable_auth,
588        authelia_installed,
589        caddy_installed,
590        inbucket_installed,
591        url.is_some(),
592        has_smtp,
593    );
594
595    let output = generate::generate_env(generate::GenerateEnvParams {
596        config: &config,
597        service_def: &reg_service.def,
598        auth_kind,
599        host_port,
600        resolved_ports: &resolved_ports,
601        env_overrides,
602        exposure,
603        extra_env,
604        pre_built_ctx,
605        enable_smtp: has_smtp,
606        enabled_groups,
607    })?;
608
609    let podman_args: Vec<String> = Vec::new();
610
611    // Declared port names, for validating ${SERVICE_PORT_*} quadlet refs.
612    let port_names: Vec<String> = resolved_ports.iter().map(|(n, _)| n.clone()).collect();
613
614    // Build install metadata persisted to `metadata.toml` in service_home.
615    // This is what makes service state authoritative for "what's installed
616    // and how is it wired" — every field a future `ryra list` needs to
617    // reconstruct the install reads back from this file.
618    // `smtp_enabled` captures *user intent* (the `enable_smtp` flag the
619    // caller passed) rather than the gated render flag (`has_smtp`).
620    // Otherwise an install on a host without globally-configured SMTP
621    // records `false`, and a later `ryra configure` that doesn't touch
622    // SMTP would still show as "modified" because the legacy default
623    // (`true`) disagrees with what gets serialized. Storing intent keeps
624    // metadata stable across re-renders and lets `ryra configure --smtp`
625    // remember the choice even before global SMTP is configured.
626    let install_metadata = Metadata {
627        registry: registry_name.to_string(),
628        url: url.map(str::to_string),
629        auth: auth_kind.cloned(),
630        provides: reg_service.def.capabilities.provides.clone(),
631        backup_enabled: enable_backup,
632        smtp_enabled: enable_smtp,
633        enabled_groups: enabled_groups.iter().cloned().collect(),
634        runtime: reg_service.def.service.runtime.clone(),
635    };
636
637    // Native services have no quadlet bundle / image: build the binary, install
638    // it, write a plain systemd --user unit, and start it. Returns here so the
639    // entire podman path below stays untouched. Reuses everything already
640    // computed: home_dir, the generated .env (`output`), ports, and metadata.
641    if reg_service.def.service.runtime == registry::service_def::Runtime::Native {
642        let tracked_envs = collect_static_envs(&reg_service.def, &output.ctx, enabled_groups)?;
643        let allocated_ports = resolved_ports.clone();
644        let generated_secrets = collect_generated_secrets(&reg_service.def, env_overrides);
645        return build_native_add(NativeAddParams {
646            service_name,
647            reg_service: &reg_service,
648            home_dir: &home_dir,
649            output,
650            install_metadata: &install_metadata,
651            registry_name,
652            url,
653            tracked_envs,
654            allocated_ports,
655            generated_secrets,
656        });
657    }
658
659    // Process quadlet bundle from registry
660    let bundle =
661        generate::bundle::process_quadlet_bundle(&generate::bundle::ProcessBundleParams {
662            service_dir: &reg_service.service_dir,
663            service_name,
664            extra_networks: &extra_networks,
665            extra_volumes: &extra_volumes,
666            podman_args: &podman_args,
667            extra_exec_start_pre: &extra_exec_start_pre,
668            port_names: &port_names,
669        })?;
670
671    // Generate warnings
672    let mut warnings = Vec::new();
673
674    if let Some(ref reqs) = reg_service.def.requirements
675        && let Some(total) = system::memory::total_ram_mb()
676    {
677        if total < reqs.ram.min {
678            warnings.push(Warning::RamBelowMinimum {
679                service_name: service_name.to_string(),
680                min_mb: reqs.ram.min,
681                available_mb: total,
682            });
683        } else if let Some(rec) = reqs.ram.recommended
684            && total < rec
685        {
686            warnings.push(Warning::RamBelowRecommended {
687                service_name: service_name.to_string(),
688                recommended_mb: rec,
689                available_mb: total,
690            });
691        }
692    }
693    warnings.extend(port_warnings);
694
695    // Build ordered steps
696    let mut steps = Vec::new();
697
698    // 1. Create service data directory
699    steps.push(Step::CreateDir(home_dir.clone()));
700
701    // Capture env content before it is moved into steps
702    let env_content = output.env_file.content.clone();
703
704    // 2. Pull all images (from quadlet bundle)
705    for image in &bundle.images {
706        steps.push(Step::PullImage {
707            image: image.clone(),
708        });
709    }
710
711    // 3. Write quadlet files from bundle (real files live in service_home)
712    //    and symlink each one into the systemd-mandated quadlet path so
713    //    quadlet's generator finds them on daemon-reload.
714    for file in bundle.quadlet_files {
715        let link = file
716            .path
717            .file_name()
718            .map(|n| quadlet_path.join(n))
719            .ok_or_else(|| {
720                Error::Bundle(format!("invalid quadlet path: {}", file.path.display()))
721            })?;
722        let target = file.path.clone();
723        steps.push(Step::WriteFile(file));
724        steps.push(Step::Symlink { link, target });
725    }
726
727    // 3b. Write metadata.toml — install record (registry, exposure, url,
728    // auth) used by `ryra list` / `remove` / `status` to reconstruct the
729    // install. Emitted *before* any step that can fail remotely (Tailscale
730    // API calls, image pulls) so a partial install can still be torn down
731    // by `remove_service` — which relies on metadata.toml to identify the
732    // install. World-readable mode (atomic_write picks 0o644 by name).
733    let metadata_content = toml::to_string_pretty(&install_metadata)?;
734    steps.push(Step::WriteFile(GeneratedFile {
735        path: metadata_path(service_name)?,
736        content: metadata_content,
737    }));
738
739    // 3c. Tailscale Services — when `--tailscale` was used, the host's
740    // existing tailscaled advertises the service at
741    // `https://<name>.<tailnet>.ts.net` (TailVIP-routed). One-time
742    // `TailscaleSetup` ensures ACL tags + auto-approval are in place;
743    // `TailscaleEnable` defines the service via admin API and runs
744    // `tailscale serve --service=...` from the host. No sidecar
745    // containers, no per-service tailscaled.
746    if mode == PlanMode::Add && exposure.is_tailscale() {
747        // Scope the Tailscale Service name by host (`<service>-<host>`)
748        // — Tailscale Services are global per tailnet, so without the
749        // suffix two ryra machines that both `ryra add vikunja --tailscale`
750        // would silently stomp each other's registration. The svc_name
751        // falls out of the exposure URL (built by
752        // `system::tailscale::derive_service_url` with the host suffix)
753        // — keeping URL as the single source of
754        // truth means `metadata.toml` round-trips and remove paths
755        // recover the same name without re-shelling tailscale.
756        let svc_name = exposure.tailscale_svc_name().ok_or_else(|| {
757            Error::InvalidServiceRef(format!(
758                "tailscale exposure for '{service_name}' has a malformed URL — \
759                 expected `https://<service>-<host>.<tailnet>.ts.net/`"
760            ))
761        })?;
762        // A multi-port service (e.g. ente: web UI + API) serves each
763        // tailscale_https port; single-port services serve their primary
764        // port at the web root. Empty only when there are no ports at all.
765        let ts_ports = plan::tailscale_ports(&reg_service.def.ports, &resolved_ports, host_port);
766        if !ts_ports.is_empty() {
767            steps.push(Step::TailscaleSetup);
768            steps.push(Step::TailscaleEnable {
769                svc_name,
770                ports: ts_ports,
771            });
772        }
773    }
774
775    // 4. Write config files from bundle
776    for file in bundle.config_files {
777        steps.push(Step::WriteFile(file));
778    }
779
780    // 4b. Copy vendored files (plugin DLLs, archives etc.) from the
781    // registry into service_home. The config pipeline is UTF-8 /
782    // template-only; binary payloads flow through CopyFile instead.
783    for (src, dst) in bundle.files {
784        steps.push(Step::CopyFile { src, dst });
785    }
786
787    // 5. Write .env file
788    steps.push(Step::WriteFile(output.env_file));
789
790    // 6. Create bind mount directories (must exist before container starts)
791    for dir in &bundle.bind_mount_dirs {
792        steps.push(Step::CreateDir(dir.clone()));
793    }
794
795    // 7. Auth-bridge artifacts (CA bundle, refresh script, host-resolve script,
796    // placeholder /etc/hosts) — needed before container starts for TLS trust
797    // and auth-domain resolution.
798    steps.extend(auth_bridge_steps);
799
800    // 8. Register OIDC client with the auth provider BEFORE starting the service.
801    // This must happen first because the service's ExecStartPost (e.g., register-oidc.sh)
802    // needs the auth provider configured and caddy's network alias in place so OIDC
803    // discovery URLs resolve correctly from within the service container.
804    //
805    // Skipped on upgrade: build_context generates a fresh client_id/secret
806    // every call, so re-registering would append a second entry to authelia's
807    // configuration.yml and break the existing OIDC integration.
808    if mode == PlanMode::Add
809        && let (
810            Some(registry::service_def::AuthKind::Oidc),
811            Some(config::schema::AuthCredentials::Authelia { .. }),
812        ) = (auth_kind, config.auth.as_ref())
813    {
814        steps.extend(authelia::register_oidc_client(
815            service_name,
816            &reg_service.def,
817            url,
818            &output.ctx,
819            &config,
820            &quadlet_path,
821        )?);
822    }
823
824    // 9. Add Caddy route for services with a URL when Caddy is installed.
825    // This creates a reverse proxy from the service's domain to its container port.
826    //
827    // Tailscale exposures skip this: the service is already reachable on
828    // its host port via the tailnet, and MagicDNS handles the hostname.
829    if let Some(url) = url
830        && !WellKnownService::Caddy.matches(service_name)
831        && !exposure.is_tailscale()
832    {
833        if caddy_installed {
834            let parsed = url::Url::parse(url)
835                .map_err(|e| Error::Template(format!("invalid service URL '{url}': {e}")))?;
836            let domain = parsed.host_str().ok_or_else(|| {
837                Error::Template(format!(
838                    "service URL '{url}' has no host — Caddy needs a hostname to route to"
839                ))
840            })?;
841            let container_port = reg_service
842                .def
843                .ports
844                .first()
845                .map(|p| p.container_port)
846                .unwrap_or(80);
847            let primary_quadlet = reg_service
848                .service_dir
849                .join("quadlets")
850                .join(format!("{service_name}.container"));
851            let target_host = caddy::primary_container_name(&primary_quadlet, service_name);
852            let block = caddy::render_site_block(&caddy::CaddySiteParams {
853                service_name: service_name.to_string(),
854                target_host,
855                domain: domain.to_string(),
856                container_port,
857                https_port: caddy_https_port(&config),
858            });
859            let caddyfile_path = caddy::caddyfile_path()?;
860            let existing =
861                std::fs::read_to_string(&caddyfile_path).map_err(|source| Error::FileRead {
862                    path: caddyfile_path.clone(),
863                    source,
864                })?;
865            let updated = caddy::add_route(&existing, service_name, &block);
866            steps.push(Step::WriteFile(GeneratedFile {
867                path: caddyfile_path,
868                content: updated,
869            }));
870            steps.push(Step::ReloadCaddy);
871        } else if let Some(primary) = host_port {
872            // --url was passed but no ryra-managed reverse proxy is installed.
873            // Templating and OIDC still work, but the user is responsible for
874            // routing <url> → 127.0.0.1:<primary> via nginx / Cloudflare Tunnel
875            // / Tailscale Funnel / etc.
876            warnings.push(Warning::UrlWithoutReverseProxy {
877                service_name: service_name.to_string(),
878                url: url.to_string(),
879                host_port: primary,
880            });
881        }
882    }
883
884    // 9b. When a shared-network provider (caddy, inbucket) is being
885    // installed, retroactively patch services that were installed before
886    // it so they can reach the new provider by container DNS.
887    // resolve_extra_networks only decides at install time; without this
888    // step, services installed earlier remain isolated.
889    //
890    // Skipped on upgrade: re-running on the same shared-network provider
891    // would re-patch services unnecessarily, and a re-render of a regular
892    // service shouldn't touch its peers' quadlets.
893    if mode == PlanMode::Add {
894        steps.extend(retroactive_network_joins(
895            service_name,
896            &quadlet_path,
897            Some(repo_dir),
898        ));
899    }
900
901    // 9d. Caddy: seed the user-owned `tls.caddy` snippet on first install.
902    // Site blocks emit `import services_tls`; this file defines that snippet.
903    // After first write ryra never touches it — users edit it directly
904    // for Cloudflare DNS-01, wildcards, BYO certs, plain HTTP for Tunnel,
905    // anything Caddy supports. seed-caddyfile.sh defensively recreates
906    // the file as `tls internal` on container start if it goes missing.
907    if WellKnownService::Caddy.matches(service_name) {
908        let snippet_path = caddy::tls_snippet_path()?;
909        if !snippet_path.exists() {
910            let mode = acme_mode.cloned().unwrap_or(caddy::AcmeMode::Internal);
911            steps.push(Step::WriteFile(GeneratedFile {
912                path: snippet_path,
913                content: mode.snippet(),
914            }));
915        }
916    }
917
918    // 9z. Manifest — sha256 list of every file we just emitted, written to
919    // `~/.local/share/services/<svc>/service.manifest` so `ryra diff` and
920    // `ryra upgrade` can detect drift between the registry and what's
921    // actually on disk. `.env` is excluded because it carries generated
922    // secrets that legitimately rotate at runtime; the manifest itself is
923    // excluded to avoid the chicken-and-egg of hashing itself. CopyFile
924    // sources (binary plugin payloads) are not yet covered — drift on
925    // those is rare and adds I/O at plan time. Revisit if it bites.
926    let manifest_path_for_svc = manifest::manifest_path(service_name)?;
927    let env_filename = std::ffi::OsStr::new(".env");
928    let mut manifest_entries: Vec<manifest::ManifestEntry> = Vec::new();
929    for step in &steps {
930        if let Step::WriteFile(file) = step {
931            if file.path == manifest_path_for_svc {
932                continue;
933            }
934            if file.path.file_name() == Some(env_filename) {
935                continue;
936            }
937            manifest_entries.push(manifest::ManifestEntry {
938                path: file.path.clone(),
939                sha256: manifest::hash_bytes(file.content.as_bytes()),
940            });
941        }
942    }
943    // Static env vars — every registry-defined env whose template carries
944    // no `{{secret.*}}` or `{{auth.*}}` reference. Tracked so `ryra
945    // upgrade` can append registry-added env vars to the user's existing
946    // `.env` without re-rendering it (which would clobber rotated
947    // secrets). Append-only by design. The richer `tracked_envs` is what
948    // upgrade uses to decide whether to prompt the user; the on-disk
949    // manifest only records key+value (the `# env: KEY=VAL` lines).
950    let tracked_envs = collect_static_envs(&reg_service.def, &output.ctx, enabled_groups)?;
951    let manifest_envs: Vec<manifest::EnvEntry> = tracked_envs
952        .iter()
953        .map(|t| manifest::EnvEntry {
954            key: t.key.clone(),
955            value: t.value.clone(),
956        })
957        .collect();
958    steps.push(Step::WriteFile(GeneratedFile {
959        path: manifest_path_for_svc,
960        content: manifest::format(&manifest_entries, &manifest_envs),
961    }));
962
963    // 10. Reload and start via systemd
964    steps.push(Step::DaemonReload);
965    // Start — dependencies start automatically via Requires=/After= in the quadlet
966    steps.push(Step::StartService {
967        unit: service_name.to_string(),
968    });
969
970    // Collect post-install info
971    let allocated_ports: Vec<(String, u16)> = resolved_ports.clone();
972
973    // Secret names from env var templates (not stored in state)
974    let mut generated_secrets: Vec<String> = reg_service
975        .def
976        .env
977        .iter()
978        .filter(|e| !env_overrides.contains_key(&e.name))
979        .flat_map(|e| generate::extract_secret_refs(&e.value))
980        .collect();
981    // Deduplicate — the same secret may be referenced by multiple env vars
982    generated_secrets.sort();
983    generated_secrets.dedup();
984
985    Ok(AddResult {
986        steps,
987        warnings,
988        repo_url: registry_name.to_string(),
989        allocated_ports,
990        generated_secrets,
991        env_content,
992        url: url.map(|u| u.to_string()),
993        tracked_envs,
994    })
995}
996
997/// Secret names referenced by a service's env templates (for the install
998/// summary; values live in `.env`, not state). Shared by add paths.
999fn collect_generated_secrets(
1000    def: &registry::service_def::ServiceDef,
1001    env_overrides: &BTreeMap<String, String>,
1002) -> Vec<String> {
1003    let mut out: Vec<String> = def
1004        .env
1005        .iter()
1006        .filter(|e| !env_overrides.contains_key(&e.name))
1007        .flat_map(|e| generate::extract_secret_refs(&e.value))
1008        .collect();
1009    out.sort();
1010    out.dedup();
1011    out
1012}
1013
1014/// Inputs for [`build_native_add`] — grouped to keep the signature sane.
1015struct NativeAddParams<'a> {
1016    service_name: &'a str,
1017    reg_service: &'a registry::RegistryService,
1018    home_dir: &'a Path,
1019    output: generate::EnvOutput,
1020    install_metadata: &'a Metadata,
1021    registry_name: &'a str,
1022    url: Option<&'a str>,
1023    tracked_envs: Vec<TrackedEnv>,
1024    allocated_ports: Vec<(String, u16)>,
1025    generated_secrets: Vec<String>,
1026}
1027
1028/// Plan a `runtime = "native"` install: build the binary (unless prebuilt),
1029/// install it, write the service `.env`, render a plain `systemd --user` unit
1030/// and link it, then start. No image, no quadlet — but the same `.env`
1031/// contract (`SERVICE_PORT_HTTP`, etc.) and the same `service_home` the rest of
1032/// ryra (Caddy routing, whole-folder backups) already understands.
1033fn build_native_add(p: NativeAddParams<'_>) -> Result<AddResult> {
1034    let NativeAddParams {
1035        service_name,
1036        reg_service,
1037        home_dir,
1038        output,
1039        install_metadata,
1040        registry_name,
1041        url,
1042        tracked_envs,
1043        allocated_ports,
1044        generated_secrets,
1045    } = p;
1046
1047    let run = reg_service.def.service.run.as_ref().ok_or_else(|| {
1048        Error::Bundle(format!(
1049            "native service '{service_name}' is missing its `run` command"
1050        ))
1051    })?;
1052    let build = reg_service.def.service.build.as_ref();
1053
1054    let env_content = output.env_file.content.clone();
1055    let source_dir = reg_service.service_dir.clone();
1056    let mut steps = Vec::new();
1057
1058    // The service home holds STATE only: data/, .env, the unit, the install
1059    // record. The service itself runs from its source dir (no binary copy), so
1060    // a plain `target/release/app`, `bun run src/index.ts`, or `cargo watch`
1061    // all work the same and a rebuild lands where the unit already looks.
1062    steps.push(Step::CreateDir(home_dir.to_path_buf()));
1063    steps.push(Step::CreateDir(home_dir.join("data")));
1064
1065    // Optional build/prepare step (cargo build, bun install) in the source dir.
1066    if let Some(command) = build {
1067        steps.push(Step::Build {
1068            dir: source_dir.clone(),
1069            command: command.clone(),
1070        });
1071    }
1072
1073    // Install record + the generated .env (carries SERVICE_PORT_HTTP).
1074    steps.push(Step::WriteFile(GeneratedFile {
1075        path: metadata_path(service_name)?,
1076        content: toml::to_string_pretty(install_metadata)?,
1077    }));
1078    steps.push(Step::WriteFile(output.env_file));
1079
1080    // The unit: real file in the service home, symlinked into the systemd
1081    // --user dir so the unit is found on daemon-reload (mirrors quadlets).
1082    let unit_name = format!("{service_name}.service");
1083    let unit_path = home_dir.join(&unit_name);
1084    steps.push(Step::WriteFile(GeneratedFile {
1085        path: unit_path.clone(),
1086        content: native_unit(
1087            home_dir,
1088            &source_dir,
1089            run,
1090            &reg_service.def.service.description,
1091        ),
1092    }));
1093    steps.push(Step::Symlink {
1094        link: systemd_user_dir()?.join(&unit_name),
1095        target: unit_path,
1096    });
1097
1098    steps.push(Step::DaemonReload);
1099    steps.push(Step::StartService {
1100        unit: service_name.to_string(),
1101    });
1102
1103    Ok(AddResult {
1104        steps,
1105        warnings: Vec::new(),
1106        repo_url: registry_name.to_string(),
1107        allocated_ports,
1108        generated_secrets,
1109        env_content,
1110        url: url.map(|u| u.to_string()),
1111        tracked_envs,
1112    })
1113}
1114
1115/// Render a plain `systemd --user` unit for a native service. `EnvironmentFile`
1116/// supplies the service `.env` (so `SERVICE_PORT_HTTP` and friends are present);
1117/// `SERVICE_HOME` points the process at its data dir, matching the contract a
1118/// container service gets via the quadlet.
1119fn native_unit(home_dir: &Path, source_dir: &Path, run: &str, description: &str) -> String {
1120    let home = home_dir.display();
1121    let source = source_dir.display();
1122    // ExecStart via `sh -c 'exec <run>'` so a binary path (`target/release/app`),
1123    // an interpreter command (`bun run src/index.ts`), and a watcher
1124    // (`cargo watch -x run`) all work the same. `exec` replaces the shell so
1125    // systemd tracks the real PID and stop/restart reach the process.
1126    //
1127    // A user-level unit's PATH is minimal, so toolchains installed under $HOME
1128    // (bun, cargo, deno, go, pipx) wouldn't be found. Prepend the common ones
1129    // (%h = the user's home) so `run = "bun ..."` / `"cargo ..."` just work.
1130    format!(
1131        "[Unit]\n\
1132         Description={description}\n\
1133         After=network.target\n\
1134         \n\
1135         [Service]\n\
1136         Type=simple\n\
1137         WorkingDirectory={source}\n\
1138         EnvironmentFile={home}/.env\n\
1139         Environment=SERVICE_HOME={home}\n\
1140         Environment=PATH=%h/.local/bin:%h/.cargo/bin:%h/.bun/bin:%h/.deno/bin:%h/go/bin:/usr/local/bin:/usr/bin:/bin\n\
1141         ExecStart=/bin/sh -c 'exec {run}'\n\
1142         Restart=always\n\
1143         RestartSec=5\n\
1144         \n\
1145         [Install]\n\
1146         WantedBy=default.target\n",
1147    )
1148}
1149
1150/// Check if a quadlet filename belongs to a service.
1151///
1152/// Matches `{service_name}.container`, `{service_name}-db.volume`, etc.
1153/// but NOT `{service_name_prefix}-other.container` (e.g., "foo" must not
1154/// match "foo-bar.container" when "foo-bar" is a known service).
1155///
1156/// `all_service_names` contains every installed service name — used to detect
1157/// when a longer service name owns the file instead.
1158pub fn quadlet_belongs_to(filename: &str, service_name: &str, all_service_names: &[&str]) -> bool {
1159    if !filename.starts_with(service_name) {
1160        return false;
1161    }
1162    let rest = &filename[service_name.len()..];
1163    if rest.starts_with('.') {
1164        return true;
1165    }
1166    if !rest.starts_with('-') {
1167        return false;
1168    }
1169    // Check that no other installed service is a longer prefix match.
1170    // e.g., "foo-bar.container" with service "foo" — if "foo-bar"
1171    // is also installed, it owns this file.
1172    !all_service_names.iter().any(|&other| {
1173        other.len() > service_name.len()
1174            && other.starts_with(service_name)
1175            && filename.starts_with(other)
1176            && filename[other.len()..].starts_with(['.', '-'])
1177    })
1178}
1179
1180/// How destructive `remove_service` should be.
1181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1182pub enum RemoveMode {
1183    /// Stop + remove quadlets + delete ephemeral config files, but keep
1184    /// the data subdirs under the service home dir and keep all podman
1185    /// named volumes. After this, `ryra data ls` reports the service as
1186    /// `Orphan`.
1187    Preserve,
1188    /// Stop + remove everything: quadlets, entire home dir, named volumes.
1189    Purge,
1190}
1191
1192/// Remove a service: update state, return cleanup steps.
1193pub fn remove_service(service_name: &str, mode: RemoveMode) -> Result<RemoveResult> {
1194    // Reconstruct the InstalledService view from the quadlet's
1195    // `# Service-*` headers — that's the source of truth now.
1196    let installed_owned = build_installed_from_metadata(service_name)
1197        .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
1198    let installed = &installed_owned;
1199
1200    // Native services have no quadlets / podman objects: tear down the
1201    // systemd --user unit and (on purge) the home dir. Runtime comes from the
1202    // install record, so this works without the registry. Mirrors the native
1203    // add path's early return.
1204    if let Ok(Some(meta)) = metadata::load_metadata(service_name)
1205        && meta.runtime == registry::service_def::Runtime::Native
1206    {
1207        let url = installed.exposure.url().map(|s| s.to_string());
1208        return remove_native_service(service_name, mode, url);
1209    }
1210
1211    // Stop all units belonging to this service (main + sidecars).
1212    // Quadlet files named {service_name}.ext or {service_name}-sidecar.ext.
1213    let quadlet_path = quadlet_dir()?;
1214    let mut steps = Vec::new();
1215    let mut volume_names = Vec::new();
1216    let mut networks: Vec<String> = Vec::new();
1217    let mut has_named_volumes = false;
1218    // Quadlet directory scan is authoritative — captures every
1219    // ryra-managed service so the "is foo-bar a sibling service?"
1220    // prefix check (used to scope file removal) sees every install.
1221    let name_pool = scan_managed_services().unwrap_or_default();
1222    let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
1223
1224    // Disable the Tailscale Service before tearing the host port down.
1225    // Always emit when the service was tailscale-enabled — the API
1226    // delete is idempotent and `tailscale serve --service=svc:X off`
1227    // is fine to run on a service that's already cleared.
1228    //
1229    // svc_name comes from the stored exposure URL (the `<service>-<host>`
1230    // first label) — pulling it from the URL captured at install time
1231    // means a hostname change post-install doesn't break teardown. If
1232    // the URL is malformed, skip the step rather than blocking the
1233    // whole removal — a stale tailnet entry is a smaller harm than a
1234    // service that won't uninstall.
1235    if let Some(svc_name) = installed.exposure.tailscale_svc_name() {
1236        steps.push(Step::TailscaleDisable { svc_name });
1237    }
1238
1239    if quadlet_path.is_dir()
1240        && let Ok(entries) = std::fs::read_dir(&quadlet_path)
1241    {
1242        for entry in entries.flatten() {
1243            let file_name = entry.file_name();
1244            let name = file_name.to_string_lossy();
1245            // Catches both the service's own quadlets (foo.container,
1246            // foo-db.container, …) and its `ts-foo*` tailscale sidecar.
1247            if !quadlet_belongs_to(&name, service_name, &all_names) {
1248                continue;
1249            }
1250            // Stop each .container unit before removing files
1251            if name.ends_with(".container") {
1252                let unit = name.trim_end_matches(".container").to_string();
1253                steps.push(Step::StopService { unit });
1254            }
1255            if name.ends_with(".network") {
1256                // Stop the generated `<net>-network` oneshot, and remember the
1257                // network so it can be dropped once every container is down.
1258                let net = name.trim_end_matches(".network").to_string();
1259                steps.push(Step::StopService {
1260                    unit: format!("{net}-network"),
1261                });
1262                networks.push(net);
1263            }
1264            if name.ends_with(".volume") {
1265                has_named_volumes = true;
1266                if matches!(mode, RemoveMode::Purge) {
1267                    let vol = name.trim_end_matches(".volume").to_string();
1268                    // Quadlet prefixes volume names with "systemd-"
1269                    volume_names.push(format!("systemd-{vol}"));
1270                }
1271            }
1272            steps.push(Step::RemoveFile(entry.path()));
1273        }
1274    }
1275
1276    // Clean up ryra-managed Caddy site block + OIDC client registration
1277    // BEFORE the daemon reload, so the routing layers drop their stale
1278    // pointers while the doomed containers are already stopped.
1279    // Caddy-routed exposures (Internal / Public) had a `# Service-Source: registry/<svc>`
1280    // block written into the Caddyfile on add; remove it now. Loopback
1281    // and Tailscale never had one (no Caddy involvement), so skip.
1282    let had_caddy_route = matches!(
1283        installed.exposure,
1284        Exposure::Internal { .. } | Exposure::Public { .. }
1285    );
1286    if !WellKnownService::Caddy.matches(service_name) && had_caddy_route {
1287        let caddyfile_path = caddy::caddyfile_path()?;
1288        if caddyfile_path.exists() {
1289            let existing =
1290                std::fs::read_to_string(&caddyfile_path).map_err(|source| Error::FileRead {
1291                    path: caddyfile_path.clone(),
1292                    source,
1293                })?;
1294            let updated = caddy::remove_route(&existing, service_name);
1295            if updated != existing {
1296                steps.push(Step::WriteFile(GeneratedFile {
1297                    path: caddyfile_path,
1298                    content: updated.clone(),
1299                }));
1300                // Skip reload if the Caddyfile is now empty — Caddy rejects
1301                // empty configs and will fail the reload.
1302                if !updated.trim().is_empty() {
1303                    steps.push(Step::ReloadCaddy);
1304                }
1305            }
1306        }
1307    }
1308
1309    if !WellKnownService::Authelia.matches(service_name)
1310        && matches!(
1311            installed.auth_kind,
1312            Some(registry::service_def::AuthKind::Oidc)
1313        )
1314    {
1315        steps.extend(authelia::unregister_oidc_client(service_name)?);
1316    }
1317
1318    // Reload systemd after removing quadlet files
1319    steps.push(Step::DaemonReload);
1320
1321    // Drop the service's podman networks now that all its containers are
1322    // stopped and the network units unloaded. `ryra remove` previously left
1323    // these behind — it deleted the `.network` file but never the network
1324    // itself — and the leak broke the next install: the regenerated network
1325    // unit's `podman network create` hit the still-present network and failed.
1326    // Best-effort — a network still used by another service is correctly
1327    // skipped (the rm fails and is ignored by the executor).
1328    for net in networks {
1329        steps.push(Step::RemoveNetwork { name: net });
1330    }
1331
1332    match mode {
1333        RemoveMode::Purge => {
1334            // Remove podman volumes after containers and units are gone
1335            for vol_name in volume_names {
1336                steps.push(Step::RemoveVolume { name: vol_name });
1337            }
1338            // Wipe entire service data directory
1339            steps.push(Step::RemoveDir(service_home(service_name)?));
1340        }
1341        RemoveMode::Preserve => {
1342            // Keep volumes intact — volume_names is guaranteed empty here
1343            // because accumulation is gated on Purge mode above.
1344            // Remove only ephemeral children of the home dir; keep data.
1345            let home = service_home(service_name)?;
1346            let (data, ephemeral) = crate::data::classify::classify_home_dir(&home)?;
1347            for path in ephemeral {
1348                match std::fs::metadata(&path) {
1349                    Ok(m) if m.is_dir() => steps.push(Step::RemoveDir(path)),
1350                    Ok(_) => steps.push(Step::RemoveFile(path)),
1351                    // Path vanished between scan and step emission.
1352                    // `rm -f` is a no-op on a missing path; keeping the step ensures a
1353                    // retry of the same plan is idempotent.
1354                    Err(_) => steps.push(Step::RemoveFile(path)),
1355                }
1356            }
1357            // If the service has no bind-mounted data *and* no podman
1358            // named volumes, preserve-mode has literally nothing to
1359            // preserve — the home dir would just be an empty ghost.
1360            // Drop it in that case. When volumes exist (twenty,
1361            // postgres, …) we keep the home dir so owner inference in
1362            // enumerate_all can still attribute the volumes back to
1363            // this service; `ryra list` then reports a real orphan.
1364            if data.is_empty() && !has_named_volumes && home.exists() {
1365                steps.push(Step::RemoveDir(home));
1366            }
1367        }
1368    }
1369
1370    let url = installed.exposure.url().map(|s| s.to_string());
1371
1372    Ok(RemoveResult {
1373        steps,
1374        service_name: service_name.to_string(),
1375        url,
1376    })
1377}
1378
1379/// Tear down a `runtime = "native"` install: stop its `systemd --user` unit,
1380/// drop the unit symlink, reload, and remove either the whole home (purge) or
1381/// just the rebuildable/ephemeral bits (preserve keeps `data/` + the install
1382/// record). The dual of [`build_native_add`].
1383fn remove_native_service(
1384    service_name: &str,
1385    mode: RemoveMode,
1386    url: Option<String>,
1387) -> Result<RemoveResult> {
1388    let home = service_home(service_name)?;
1389    let unit_name = format!("{service_name}.service");
1390    let mut steps = vec![
1391        Step::StopService {
1392            unit: service_name.to_string(),
1393        },
1394        Step::RemoveFile(systemd_user_dir()?.join(&unit_name)),
1395        Step::DaemonReload,
1396    ];
1397
1398    match mode {
1399        RemoveMode::Purge => steps.push(Step::RemoveDir(home)),
1400        RemoveMode::Preserve => {
1401            // Keep data/ and metadata.toml; drop the rebuildable binary, the
1402            // generated .env, and the unit file (all re-created on re-add).
1403            for child in ["bin", ".env", unit_name.as_str()] {
1404                let p = home.join(child);
1405                match std::fs::metadata(&p) {
1406                    Ok(m) if m.is_dir() => steps.push(Step::RemoveDir(p)),
1407                    _ => steps.push(Step::RemoveFile(p)),
1408                }
1409            }
1410        }
1411    }
1412
1413    Ok(RemoveResult {
1414        steps,
1415        service_name: service_name.to_string(),
1416        url,
1417    })
1418}
1419
1420/// A lifecycle transition applied to an installed service's unit family.
1421#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1422pub enum Lifecycle {
1423    Start,
1424    Stop,
1425}
1426
1427/// Plan a start/stop of an installed service's full unit family (main
1428/// container + sidecars). Errors with [`Error::ServiceNotInstalled`] if
1429/// the service isn't installed.
1430///
1431/// systemd cascades *start* through `Requires=`, but never cascades
1432/// *stop* — so every `.container` unit is named explicitly and the steps
1433/// are ordered to respect dependencies: the main app unit stops first
1434/// (before its db/cache sidecars) and starts last (after them).
1435pub fn lifecycle_steps(service_name: &str, action: Lifecycle) -> Result<Vec<Step>> {
1436    // Same validation + error surface as `remove_service`.
1437    build_installed_from_metadata(service_name)
1438        .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
1439
1440    // Native services are a single systemd --user unit named after the service
1441    // (no sidecars / quadlets).
1442    if matches!(
1443        metadata::load_metadata(service_name),
1444        Ok(Some(m)) if m.runtime == registry::service_def::Runtime::Native
1445    ) {
1446        let unit = service_name.to_string();
1447        return Ok(vec![match action {
1448            Lifecycle::Start => Step::StartService { unit },
1449            Lifecycle::Stop => Step::StopService { unit },
1450        }]);
1451    }
1452
1453    let mut units = service_container_units(service_name)?;
1454    match action {
1455        // Main unit first → stops before the sidecars it depends on.
1456        Lifecycle::Stop => units.sort_by_key(|u| u != service_name),
1457        // Main unit last → starts after the sidecars it depends on.
1458        Lifecycle::Start => units.sort_by_key(|u| u == service_name),
1459    }
1460
1461    Ok(units
1462        .into_iter()
1463        .map(|unit| match action {
1464            Lifecycle::Start => Step::StartService { unit },
1465            Lifecycle::Stop => Step::StopService { unit },
1466        })
1467        .collect())
1468}
1469
1470/// systemd unit base names of every `.container` quadlet belonging to a
1471/// service (main container, sidecars, and the `ts-<svc>` tailscale
1472/// sidecar). Mirrors the family scan in [`remove_service`].
1473fn service_container_units(service_name: &str) -> Result<Vec<String>> {
1474    let quadlet_path = quadlet_dir()?;
1475    let name_pool = scan_managed_services().unwrap_or_default();
1476    let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
1477
1478    let mut units = Vec::new();
1479    if quadlet_path.is_dir()
1480        && let Ok(entries) = std::fs::read_dir(&quadlet_path)
1481    {
1482        for entry in entries.flatten() {
1483            let file_name = entry.file_name();
1484            let name = file_name.to_string_lossy();
1485            if !quadlet_belongs_to(&name, service_name, &all_names) {
1486                continue;
1487            }
1488            if name.ends_with(".container") {
1489                units.push(name.trim_end_matches(".container").to_string());
1490            }
1491        }
1492    }
1493    Ok(units)
1494}
1495
1496/// Parameters for [`record_pending`].
1497pub struct RecordPendingParams<'a> {
1498    pub service_name: &'a str,
1499    pub auth_kind: Option<registry::service_def::AuthKind>,
1500    pub registry_name: &'a str,
1501    pub allocated_ports: &'a [(String, u16)],
1502    pub repo_dir: &'a Path,
1503    /// How the service is exposed to clients. Replaces the previous
1504    /// `(url: Option<&str>, tailscale_enabled: bool)` pair so callers
1505    /// can't construct invalid combinations like a `*.ts.net` URL with
1506    /// `tailscale_enabled = false`. Decomposed into the legacy storage
1507    /// fields inside `record_pending` until the schema migrates to
1508    /// hold the typed enum directly.
1509    pub exposure: &'a Exposure,
1510}
1511
1512/// Record a service as pending installation (installed: false).
1513/// Called BEFORE executing steps so that partial failures are recoverable.
1514/// Persist install-time scaffolding to `preferences.toml`. This is now
1515/// the only side-effect — quadlet headers track the install itself,
1516/// preferences just remembers cross-cutting defaults so the next
1517/// `ryra add --auth` doesn't have to re-prompt for the OIDC issuer.
1518pub fn record_pending(params: RecordPendingParams<'_>) -> Result<()> {
1519    let paths = ConfigPaths::resolve()?;
1520    paths.ensure_dirs()?;
1521    let mut config = config::load_or_default(&paths.config_file)?;
1522
1523    // Auto-configure [auth] when an auth provider is installed so
1524    // future `ryra add <svc> --auth` calls know where to wire the
1525    // OIDC client. The `services` array is not touched — quadlet
1526    // headers are the source of truth for what's installed.
1527    if WellKnownService::Authelia.matches(params.service_name) {
1528        config.auth = Some(authelia::auth_config(
1529            params.allocated_ports,
1530            params.exposure.url(),
1531        )?);
1532        config::save_config(&paths.config_file, &config)?;
1533    }
1534
1535    Ok(())
1536}
1537
1538/// Drop the cached `[auth]` block when the auth provider is removed —
1539/// otherwise a later `ryra add <svc> --auth` thinks auth is still
1540/// configured and skips the auto-install path, then bombs out trying
1541/// to register an OIDC client against a non-existent authelia config.
1542/// The function name is preserved for caller compatibility; quadlet
1543/// removal is what actually finalises the install state.
1544pub fn finalize_remove(service_name: &str) -> Result<()> {
1545    let paths = ConfigPaths::resolve()?;
1546    let mut config = config::load_or_default(&paths.config_file)?;
1547
1548    if WellKnownService::Authelia.matches(service_name)
1549        && let Some(auth) = &config.auth
1550        && auth.provider_name() == "authelia"
1551    {
1552        config.auth = None;
1553        config::save_config(&paths.config_file, &config)?;
1554    }
1555
1556    Ok(())
1557}
1558
1559/// Steps to purge leftover data/volumes for an orphan service — one
1560/// with data on disk but no live install (e.g., after `ryra remove
1561/// <svc>` in default Preserve mode, or after a partial install where
1562/// the quadlets landed but `metadata.toml` never did). Unlike
1563/// `remove_service`, this doesn't require an install record to exist.
1564/// Templates whose rendered value is "sensitive" — either because the
1565/// value itself is a secret/credential, or because it rotates with each
1566/// install (so tracking it as static produces false drift positives).
1567/// Anything referencing one of these is excluded from the manifest.
1568///
1569/// Crucially this is *narrower* than "every {{auth.*}} reference":
1570/// `{{auth.url}}`, `{{auth.issuer}}`, `{{auth.provider}}`, `{{auth.internal_url}}`
1571/// are all stable per-install URLs/strings that the user benefits from
1572/// having tracked (so a global authelia URL change is caught by diff).
1573/// Only the credential pair `auth.client_id` + `auth.client_secret`
1574/// rotate per install. Same for SMTP: `smtp.host`/`smtp.port`/`smtp.from`/
1575/// `smtp.security` are tracked, only `smtp.username` and `smtp.password`
1576/// are excluded.
1577const SENSITIVE_TEMPLATE_REFS: &[&str] = &[
1578    "{{secret.",
1579    "{{auth.client_id",
1580    "{{auth.client_secret",
1581    "{{smtp.username",
1582    "{{smtp.password",
1583];
1584
1585fn is_static_template(value: &str) -> bool {
1586    !SENSITIVE_TEMPLATE_REFS.iter().any(|s| value.contains(s))
1587}
1588
1589/// Render every static env var the registry expects in `.env` for the
1590/// service. "Static" means the template carries no reference to any
1591/// rotating per-install value (see `SENSITIVE_TEMPLATE_REFS`).
1592///
1593/// Walks four sources, in the same order they're rendered into `.env` by
1594/// `generate::generate_env`:
1595///   1. `service_def.env` — top-level static entries.
1596///   2. Each enabled `[[env_group]]` — opt-in bundles.
1597///   3. `service_def.mappings.smtp` — only when SMTP is configured globally
1598///      and the service opts in (`integrations.smtp`).
1599///   4. `service_def.mappings.auth` — only when `--auth` was used.
1600///
1601/// Capturing 3 and 4 is what makes global-config drift visible: when the
1602/// user reconfigures global SMTP / re-installs authelia, the per-service
1603/// mapping values change, and tracking them lets `ryra diff` notice.
1604fn collect_static_envs(
1605    service_def: &registry::service_def::ServiceDef,
1606    ctx: &BTreeMap<String, String>,
1607    enabled_groups: &std::collections::BTreeSet<String>,
1608) -> Result<Vec<plan::TrackedEnv>> {
1609    use registry::service_def::EnvKind;
1610    let mut out: Vec<plan::TrackedEnv> = Vec::new();
1611    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
1612    let push = |name: &str,
1613                value_template: &str,
1614                kind: EnvKind,
1615                prompt: Option<String>,
1616                out: &mut Vec<plan::TrackedEnv>,
1617                seen: &mut std::collections::HashSet<String>|
1618     -> Result<()> {
1619        if !is_static_template(value_template) {
1620            return Ok(());
1621        }
1622        if !seen.insert(name.to_string()) {
1623            return Ok(());
1624        }
1625        let value = generate::template::render(value_template, ctx)?;
1626        out.push(plan::TrackedEnv {
1627            key: name.to_string(),
1628            value,
1629            kind,
1630            prompt,
1631        });
1632        Ok(())
1633    };
1634    for env in &service_def.env {
1635        push(
1636            &env.name,
1637            &env.value,
1638            env.kind.clone(),
1639            env.prompt.clone(),
1640            &mut out,
1641            &mut seen,
1642        )?;
1643    }
1644    for group in &service_def.env_groups {
1645        if !enabled_groups.contains(&group.name) {
1646            continue;
1647        }
1648        for env in &group.env {
1649            push(
1650                &env.name,
1651                &env.value,
1652                env.kind.clone(),
1653                env.prompt.clone(),
1654                &mut out,
1655                &mut seen,
1656            )?;
1657        }
1658    }
1659    // Mirror the gating from `generate::render_env_vars`: SMTP mappings
1660    // only fire when smtp is configured globally; auth mappings only when
1661    // --auth was used. ctx-key presence is a faithful proxy for both.
1662    // Mapping-emitted env vars are always treated as Default (silent
1663    // append on upgrade) — there's no user-facing prompt label for them.
1664    if service_def.integrations.smtp && ctx.contains_key("smtp.host") {
1665        for (env_name, value_template) in &service_def.mappings.smtp {
1666            push(
1667                env_name,
1668                value_template,
1669                EnvKind::Default,
1670                None,
1671                &mut out,
1672                &mut seen,
1673            )?;
1674        }
1675    }
1676    if ctx.contains_key("auth.client_id") {
1677        for (env_name, value_template) in &service_def.mappings.auth {
1678            push(
1679                env_name,
1680                value_template,
1681                EnvKind::Default,
1682                None,
1683                &mut out,
1684                &mut seen,
1685            )?;
1686        }
1687    }
1688    Ok(out)
1689}
1690
1691pub fn orphan_purge_steps(svc: &data::ServiceData) -> Vec<Step> {
1692    let mut steps = Vec::new();
1693
1694    // Quadlet files in `~/.config/containers/systemd/` belonging to
1695    // this service. Mirrors `remove_service`'s sweep — filename match
1696    // via `quadlet_belongs_to` catches both regular files and
1697    // symlinks, so a re-`ryra add` after purge starts clean instead
1698    // of seeing a leftover `.volume` and re-prompting about orphan data.
1699    let mut had_quadlet = false;
1700    let mut networks: Vec<String> = Vec::new();
1701    if let Ok(qdir) = quadlet_dir()
1702        && qdir.is_dir()
1703        && let Ok(entries) = std::fs::read_dir(&qdir)
1704    {
1705        let name_pool = scan_managed_services().unwrap_or_default();
1706        let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
1707        for entry in entries.flatten() {
1708            let file_name = entry.file_name();
1709            let name = file_name.to_string_lossy();
1710            if !quadlet_belongs_to(&name, &svc.service, &all_names) {
1711                continue;
1712            }
1713            // Stop generated units before removing files so the
1714            // upcoming daemon-reload unloads them cleanly instead of
1715            // leaving "loaded: not-found, active (exited)" entries.
1716            if name.ends_with(".container") {
1717                let unit = name.trim_end_matches(".container").to_string();
1718                steps.push(Step::StopService { unit });
1719            } else if name.ends_with(".network") {
1720                let net = name.trim_end_matches(".network").to_string();
1721                steps.push(Step::StopService {
1722                    unit: format!("{net}-network"),
1723                });
1724                networks.push(net);
1725            } else if name.ends_with(".volume") {
1726                let unit = format!("{}-volume", name.trim_end_matches(".volume"));
1727                steps.push(Step::StopService { unit });
1728            }
1729            steps.push(Step::RemoveFile(entry.path()));
1730            had_quadlet = true;
1731        }
1732    }
1733    if had_quadlet {
1734        steps.push(Step::DaemonReload);
1735    }
1736    // Drop the podman networks once the containers are down (see remove_service).
1737    for net in networks {
1738        steps.push(Step::RemoveNetwork { name: net });
1739    }
1740
1741    for path in &svc.data_paths {
1742        if path.is_dir() {
1743            steps.push(Step::RemoveDir(path.clone()));
1744        } else {
1745            steps.push(Step::RemoveFile(path.clone()));
1746        }
1747    }
1748    if svc.home_dir.exists() {
1749        steps.push(Step::RemoveDir(svc.home_dir.clone()));
1750    }
1751    for v in &svc.volumes {
1752        steps.push(Step::RemoveVolume {
1753            name: v.name.clone(),
1754        });
1755    }
1756    steps
1757}
1758
1759/// Reset ryra: tear down all services, infrastructure, and config.
1760pub fn reset() -> Result<ResetResult> {
1761    let mut steps = Vec::new();
1762
1763    // Quadlet directory scan is the source of truth for ryra-managed
1764    // services — every install stamps a marker comment on the main
1765    // `.container`, so this catches every install regardless of the
1766    // state of `preferences.toml`.
1767    let managed_names = scan_managed_services().unwrap_or_default();
1768
1769    // 0. Disable every Tailscale Service before tearing services down.
1770    // `TailscaleDisable` stops `tailscale serve --service=svc:<X>` and
1771    // deletes the admin-side service definition via the API, so the
1772    // tailnet is clean after reset and the next install gets bare
1773    // hostnames. Read exposure from the quadlet headers so this still
1774    // works after the services array goes away.
1775    for svc in list_installed().unwrap_or_default() {
1776        if let Some(svc_name) = svc.exposure.tailscale_svc_name() {
1777            steps.push(Step::TailscaleDisable { svc_name });
1778        }
1779    }
1780
1781    // 1. Stop and remove only ryra-managed quadlet files (scoped by installed service names)
1782    let quadlet_path = quadlet_dir()?;
1783    let all_names: Vec<&str> = managed_names.iter().map(|s| s.as_str()).collect();
1784    let mut networks: Vec<String> = Vec::new();
1785    if quadlet_path.is_dir()
1786        && let Ok(entries) = std::fs::read_dir(&quadlet_path)
1787    {
1788        for entry in entries.flatten() {
1789            let file_name = entry.file_name();
1790            let name = file_name.to_string_lossy();
1791            // Only touch files belonging to a ryra-managed service —
1792            // including the `ts-<service>` tailscale sidecars when the
1793            // service was installed with --tailscale.
1794            let is_ryra_file = managed_names
1795                .iter()
1796                .any(|svc| quadlet_belongs_to(&name, svc, &all_names));
1797            if !is_ryra_file {
1798                continue;
1799            }
1800            if name.ends_with(".container") {
1801                let unit = name.trim_end_matches(".container").to_string();
1802                steps.push(Step::StopService { unit });
1803            }
1804            if name.ends_with(".network") {
1805                let net = name.trim_end_matches(".network").to_string();
1806                steps.push(Step::StopService {
1807                    unit: format!("{net}-network"),
1808                });
1809                networks.push(net);
1810            }
1811            if name.ends_with(".volume") {
1812                let vol = name.trim_end_matches(".volume").to_string();
1813                // Quadlet auto-generates `<vol>-volume.service` for each
1814                // `.volume` file. Stopping it before we remove the file
1815                // makes systemd unload the unit on the upcoming
1816                // daemon-reload — without this, leftover oneshot units
1817                // sit in "loaded: not-found, active (exited)" forever
1818                // until logout.
1819                steps.push(Step::StopService {
1820                    unit: format!("{vol}-volume"),
1821                });
1822            }
1823            steps.push(Step::RemoveFile(entry.path()));
1824        }
1825    }
1826
1827    // 1b. Native services keep their unit in the systemd --user dir, not the
1828    // quadlet dir, so the scan above misses them. Sweep ryra's native installs:
1829    // each home dir whose install record says `runtime = native` gets its unit
1830    // stopped and its `systemd/user` symlink removed (before the reload below
1831    // unloads it, and before step 4 wipes the data root).
1832    let user_unit_dir = systemd_user_dir()?;
1833    if let Ok(root) = service_data_root()
1834        && let Ok(entries) = std::fs::read_dir(&root)
1835    {
1836        for entry in entries.flatten() {
1837            let Some(name) = entry.file_name().to_str().map(str::to_string) else {
1838                continue;
1839            };
1840            if matches!(
1841                metadata::load_metadata(&name),
1842                Ok(Some(m)) if m.runtime == registry::service_def::Runtime::Native
1843            ) {
1844                steps.push(Step::StopService { unit: name.clone() });
1845                steps.push(Step::RemoveFile(
1846                    user_unit_dir.join(format!("{name}.service")),
1847                ));
1848            }
1849        }
1850    }
1851
1852    // 2. Reload user systemd after removing quadlets
1853    steps.push(Step::DaemonReload);
1854
1855    // 2b. Drop the podman networks now that every container is stopped and the
1856    // network units unloaded (see remove_service for why the leak matters).
1857    for net in networks {
1858        steps.push(Step::RemoveNetwork { name: net });
1859    }
1860
1861    // 3. Remove podman volumes for every ryra-visible service — installed
1862    // and orphaned. `enumerate_all` walks both the quadlet markers and the
1863    // data root, so volumes left behind by a `ryra remove --preserve`
1864    // (which drops the quadlet but keeps the named volume) get swept up
1865    // here too.
1866    let mut seen_volumes = std::collections::BTreeSet::new();
1867    for svc in data::enumerate_all().unwrap_or_default() {
1868        for vol in svc.volumes {
1869            if seen_volumes.insert(vol.name.clone()) {
1870                steps.push(Step::RemoveVolume { name: vol.name });
1871            }
1872        }
1873    }
1874
1875    // 4. Nuke the entire service data root in one shot. The user-facing
1876    // reset prompt promises "Delete ~/.local/share/services/", so the
1877    // implementation must match — sweeping managed dirs, orphan dirs
1878    // (left by `--preserve` removes), the top-level caddy-root-ca.crt,
1879    // and any other ryra-written tooling state living under that root.
1880    let data_root = service_data_root()?;
1881    if data_root.exists() {
1882        steps.push(Step::RemoveDir(data_root));
1883    }
1884
1885    Ok(ResetResult { steps })
1886}
1887
1888/// Called after reset steps succeed — removes ryra's config directory.
1889pub fn finalize_reset() -> Result<()> {
1890    let paths = ConfigPaths::resolve()?;
1891    if paths.config_dir.exists() {
1892        std::fs::remove_dir_all(&paths.config_dir).map_err(|source| Error::FileWrite {
1893            path: paths.config_dir,
1894            source,
1895        })?;
1896    }
1897    Ok(())
1898}
1899
1900/// Get the current status of the ryra installation. Considers ryra
1901/// "initialized" when EITHER a marker'd quadlet is on disk OR a
1902/// `preferences.toml` exists — quadlets are the source of truth for
1903/// installed services, but a preferences-only state (e.g. an SMTP relay
1904/// configured before any service install) still counts.
1905pub fn status() -> config::status::RyraStatus {
1906    let paths = match ConfigPaths::resolve() {
1907        Ok(p) => p,
1908        Err(_) => return config::status::RyraStatus::NotInitialized,
1909    };
1910
1911    let has_quadlets = scan_managed_services()
1912        .map(|n| !n.is_empty())
1913        .unwrap_or(false);
1914
1915    let config = match config::load_config(&paths.config_file) {
1916        Ok(c) => c,
1917        Err(Error::ConfigNotFound(_)) if has_quadlets => config::schema::Config::default(),
1918        Err(Error::ConfigNotFound(_)) => return config::status::RyraStatus::NotInitialized,
1919        Err(e) => return config::status::RyraStatus::Error(e.to_string()),
1920    };
1921
1922    config::status::RyraStatus::Initialized(config::status::StatusInfo::from_config(
1923        paths.config_file,
1924        &config,
1925    ))
1926}
1927
1928/// True if the named service is ryra-managed and *fully* installed —
1929/// the marker'd `.container` is present AND `metadata.toml` exists.
1930/// A partial install (quadlets written but the install plan errored
1931/// before metadata.toml landed) is treated as not-installed so that
1932/// `ryra remove <svc> --purge` routes through the orphan-cleanup path
1933/// instead of failing with "service is not installed". Same source of
1934/// truth as [`list_installed`].
1935pub fn is_service_installed(name: &str) -> bool {
1936    // The install record says whether (and how) a service is installed. Native
1937    // services have no quadlet — their presence is the systemd --user unit;
1938    // podman services are the marker'd quadlet. No metadata → not installed.
1939    let Ok(Some(meta)) = metadata::load_metadata(name) else {
1940        return false;
1941    };
1942    match meta.runtime {
1943        registry::service_def::Runtime::Native => systemd_user_dir()
1944            .map(|d| d.join(format!("{name}.service")).exists())
1945            .unwrap_or(false),
1946        registry::service_def::Runtime::Podman => scan_managed_services()
1947            .map(|names| names.iter().any(|n| n == name))
1948            .unwrap_or(false),
1949    }
1950}
1951
1952/// Scan the user's quadlet directory for ryra-managed services. A
1953/// `.container` file is considered ryra-managed iff it carries a
1954/// `# Service-Source: registry/<name>` comment within its first 16
1955/// lines (added at install time). Returns the deduplicated set of
1956/// service names found.
1957///
1958/// This makes the on-disk quadlet directory the source of truth for
1959/// "which services are installed" — `preferences.toml` was historically
1960/// authoritative, but could drift (e.g. if config was wiped while
1961/// services kept running). Callers that want richer metadata (URL,
1962/// exposure) still need `preferences.toml`; for "is X installed" the
1963/// quadlet scan is reliable.
1964pub fn scan_managed_services() -> Result<Vec<String>> {
1965    let dir = match quadlet_dir() {
1966        Ok(d) => d,
1967        Err(_) => return Ok(Vec::new()),
1968    };
1969    let entries = match std::fs::read_dir(&dir) {
1970        Ok(e) => e,
1971        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
1972        Err(source) => return Err(Error::FileRead { path: dir, source }),
1973    };
1974    let mut names: Vec<String> = Vec::new();
1975    for entry in entries.flatten() {
1976        let path = entry.path();
1977        if path.extension().and_then(|e| e.to_str()) != Some("container") {
1978            continue;
1979        }
1980        let Ok(content) = std::fs::read_to_string(&path) else {
1981            continue;
1982        };
1983        for line in content.lines().take(16) {
1984            if let Some(rest) = line.trim().strip_prefix("# Service-Source: registry/")
1985                && !rest.is_empty()
1986                && !names.iter().any(|n| n == rest)
1987            {
1988                names.push(rest.to_string());
1989                break;
1990            }
1991        }
1992    }
1993    names.sort();
1994    Ok(names)
1995}
1996
1997/// Build a full [`InstalledService`] from `metadata.toml` + `.env`.
1998/// Returns `None` if metadata.toml is missing — that's the signal that
1999/// either the service was never installed or it was installed by a
2000/// pre-metadata.toml ryra (in which case the caller should treat it as
2001/// not-installed and let the user reinstall).
2002fn build_installed_from_metadata(service_name: &str) -> Option<InstalledService> {
2003    let meta = load_metadata(service_name).ok().flatten()?;
2004
2005    // Loopback when no URL; otherwise classify by hostname suffix.
2006    let exposure = match meta.url.as_deref() {
2007        None => Exposure::Loopback,
2008        Some(u) => Exposure::from_url(u),
2009    };
2010
2011    let auth_kind = meta.auth.clone();
2012
2013    // Ports come from the `.env` file ryra writes alongside the quadlet
2014    // — `SERVICE_PORT_<NAME>=<value>` lines map back to the BTreeMap
2015    // keyed by lowercase name. Missing `.env` is treated as empty (still
2016    // a valid install — services without published ports legitimately
2017    // omit it).
2018    let ports = service_home(service_name)
2019        .ok()
2020        .and_then(|home| std::fs::read_to_string(home.join(".env")).ok())
2021        .map(|env| {
2022            env.lines()
2023                .filter_map(|l| {
2024                    let l = l.trim();
2025                    if l.is_empty() || l.starts_with('#') {
2026                        return None;
2027                    }
2028                    let (key, val) = l.split_once('=')?;
2029                    let name = key.strip_prefix("SERVICE_PORT_")?.to_lowercase();
2030                    let port = val
2031                        .trim_matches(|c: char| c == '"' || c == '\'')
2032                        .parse::<u16>()
2033                        .ok()?;
2034                    Some((name, port))
2035                })
2036                .collect::<std::collections::BTreeMap<String, u16>>()
2037        })
2038        .unwrap_or_default();
2039
2040    Some(InstalledService {
2041        name: service_name.to_string(),
2042        version: "0.1.0".to_string(),
2043        repo: meta.registry,
2044        ports,
2045        auth_kind,
2046        exposure,
2047        provides: meta.provides,
2048        installed: true,
2049    })
2050}
2051
2052/// List installed services. **Quadlet directory is the source of
2053/// truth** — every service whose main `.container` file carries our
2054/// marker is reconstructed from its on-disk headers + `.env`. The
2055/// preferences file is only consulted as a fallback for entries the
2056/// scan can't see (e.g. partially-rolled-out installs from older
2057/// ryra versions before metadata headers landed).
2058pub fn list_installed() -> Result<Vec<InstalledService>> {
2059    let mut names: std::collections::BTreeSet<String> = scan_managed_services()
2060        .unwrap_or_default()
2061        .into_iter()
2062        .collect();
2063    // Native services carry no quadlet marker. Pick them up from the data root:
2064    // any home dir that `is_service_installed` confirms (runtime-aware) and that
2065    // the quadlet scan didn't already catch.
2066    if let Ok(root) = service_data_root()
2067        && let Ok(entries) = std::fs::read_dir(&root)
2068    {
2069        for entry in entries.flatten() {
2070            if let Some(name) = entry.file_name().to_str()
2071                && !names.contains(name)
2072                && is_service_installed(name)
2073            {
2074                names.insert(name.to_string());
2075            }
2076        }
2077    }
2078    let out: Vec<InstalledService> = names
2079        .iter()
2080        .filter_map(|n| build_installed_from_metadata(n))
2081        .collect();
2082    Ok(out)
2083}
2084
2085/// Search available services in a repo, optionally filtered by query.
2086pub fn search_services(repo_dir: &Path, query: Option<&str>) -> Result<Vec<SearchResult>> {
2087    let available = registry::list_available(repo_dir)?;
2088
2089    let results = available
2090        .into_iter()
2091        .filter(|reg_svc| match query {
2092            None => true,
2093            Some(q) => {
2094                let q = q.to_lowercase();
2095                reg_svc.def.service.name.to_lowercase().contains(&q)
2096                    || reg_svc.def.service.description.to_lowercase().contains(&q)
2097            }
2098        })
2099        .map(|reg_svc| {
2100            let name = &reg_svc.def.service.name;
2101            let installed = is_service_installed(name);
2102            let mut supports = Vec::new();
2103            for kind in &reg_svc.def.integrations.auth {
2104                supports.push(kind.to_string());
2105            }
2106            if reg_svc.def.integrations.smtp {
2107                supports.push("smtp".to_string());
2108            }
2109            SearchResult {
2110                name: name.clone(),
2111                description: reg_svc.def.service.description,
2112                installed,
2113                supports,
2114            }
2115        })
2116        .collect();
2117
2118    Ok(results)
2119}
2120
2121pub struct SearchResult {
2122    pub name: String,
2123    pub description: String,
2124    pub installed: bool,
2125    /// Integrations this service supports (e.g., "oidc", "smtp").
2126    pub supports: Vec<String>,
2127}
2128
2129/// Get test definitions for an installed service by reading its `test.toml`.
2130pub async fn service_tests(service_name: &str) -> Result<ServiceTestInfo> {
2131    let installed = build_installed_from_metadata(service_name)
2132        .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
2133
2134    let service_ref = service_ref_from_installed(&installed);
2135    let repo_dir = resolve_registry_dir(&service_ref).await?;
2136
2137    let test_toml_path = repo_dir.join(service_name).join("test.toml");
2138    let env_file = service_home(service_name)?.join(".env");
2139
2140    if !test_toml_path.exists() {
2141        return Ok(ServiceTestInfo {
2142            service_name: service_name.to_string(),
2143            registry_name: service_ref.registry_name().to_string(),
2144            tests: vec![],
2145            env_file,
2146        });
2147    }
2148
2149    let content = std::fs::read_to_string(&test_toml_path).map_err(|source| Error::FileRead {
2150        path: test_toml_path.clone(),
2151        source,
2152    })?;
2153
2154    #[derive(serde::Deserialize)]
2155    struct TestFile {
2156        #[serde(default)]
2157        tests: Vec<registry::test_def::TestDef>,
2158    }
2159
2160    let parsed: TestFile = toml::from_str(&content).map_err(|source| Error::TomlParse {
2161        path: test_toml_path,
2162        source,
2163    })?;
2164
2165    Ok(ServiceTestInfo {
2166        service_name: service_name.to_string(),
2167        registry_name: service_ref.registry_name().to_string(),
2168        tests: parsed.tests,
2169        env_file,
2170    })
2171}
2172
2173pub struct ServiceTestInfo {
2174    pub service_name: String,
2175    pub registry_name: String,
2176    pub tests: Vec<registry::test_def::TestDef>,
2177    pub env_file: PathBuf,
2178}
2179
2180/// Get detailed info about a service from a repo.
2181pub fn service_info(repo_dir: &Path, service_name: &str) -> Result<ServiceDetail> {
2182    let reg_service = registry::find_service(repo_dir, service_name)?;
2183    let def = &reg_service.def;
2184
2185    Ok(ServiceDetail {
2186        name: def.service.name.clone(),
2187        description: def.service.description.clone(),
2188        url: def.service.url.clone(),
2189        ports: def
2190            .ports
2191            .iter()
2192            .map(|p| (p.container_port, p.protocol.clone(), p.name.clone()))
2193            .collect(),
2194        env_vars: def
2195            .env
2196            .iter()
2197            .map(|e| (e.name.clone(), e.prompt.clone()))
2198            .collect(),
2199    })
2200}
2201
2202pub struct ServiceDetail {
2203    pub name: String,
2204    pub description: String,
2205    pub url: Option<String>,
2206    pub ports: Vec<(u16, registry::service_def::PortProtocol, String)>,
2207    pub env_vars: Vec<(String, Option<String>)>,
2208}
2209
2210#[cfg(test)]
2211mod tests {
2212    use super::*;
2213
2214    #[test]
2215    fn static_template_filter_excludes_secrets_and_credentials() {
2216        // Plain literal — tracked.
2217        assert!(is_static_template("3306"));
2218        assert!(is_static_template("mariadb"));
2219        // Stable template references — tracked.
2220        assert!(is_static_template("{{service.port}}"));
2221        assert!(is_static_template("{{service.url}}"));
2222        assert!(is_static_template("{{auth.url}}"));
2223        assert!(is_static_template("{{auth.issuer}}"));
2224        assert!(is_static_template("{{auth.provider}}"));
2225        assert!(is_static_template("{{auth.internal_url}}"));
2226        assert!(is_static_template("{{smtp.host}}"));
2227        assert!(is_static_template("{{smtp.port}}"));
2228        assert!(is_static_template("{{smtp.from}}"));
2229        // Composite template: stable + stable — tracked.
2230        assert!(is_static_template("{{service.url}}/oauth/callback"));
2231
2232        // Secrets — never tracked.
2233        assert!(!is_static_template("{{secret.admin_password}}"));
2234        assert!(!is_static_template("{{secret.jwt_key}}"));
2235        // Per-install OIDC credentials — never tracked (rotates on auth provider reinstall).
2236        assert!(!is_static_template("{{auth.client_id}}"));
2237        assert!(!is_static_template("{{auth.client_secret}}"));
2238        // SMTP credentials — never tracked.
2239        assert!(!is_static_template("{{smtp.username}}"));
2240        assert!(!is_static_template("{{smtp.password}}"));
2241        // Composite templates carrying a sensitive ref must also be excluded.
2242        assert!(!is_static_template(
2243            "redis://:{{secret.redis_pw}}@host:6379"
2244        ));
2245    }
2246
2247    #[test]
2248    fn tailscale_url_matches() {
2249        assert!(is_tailscale_url("http://debian.cobbler-tuna.ts.net"));
2250        assert!(is_tailscale_url("http://debian.cobbler-tuna.ts.net:10001/"));
2251        assert!(is_tailscale_url("https://foo.example-net.ts.net"));
2252        assert!(is_tailscale_url("http://HOST.COBBLER-TUNA.TS.NET"));
2253    }
2254
2255    #[test]
2256    fn tailscale_url_rejects() {
2257        assert!(!is_tailscale_url("https://nextcloud.internal:8443"));
2258        assert!(!is_tailscale_url("https://example.com"));
2259        assert!(!is_tailscale_url("http://127.0.0.1:10001"));
2260        // lookalike — must be exact `.ts.net` suffix
2261        assert!(!is_tailscale_url("https://ts.net"));
2262        assert!(!is_tailscale_url("https://evil-ts.net.example.com"));
2263        assert!(!is_tailscale_url("not a url"));
2264    }
2265
2266    #[test]
2267    fn public_url_accepts_public_domains() {
2268        assert!(is_public_url("https://seafile.ryra.no"));
2269        assert!(is_public_url("https://example.com"));
2270        assert!(is_public_url("https://docs.ryra.no:8443"));
2271    }
2272
2273    #[test]
2274    fn public_url_rejects_lan_and_tailnet() {
2275        assert!(!is_public_url("https://nextcloud.internal:8443"));
2276        assert!(!is_public_url("https://service.localhost"));
2277        assert!(!is_public_url("https://something.local"));
2278        assert!(!is_public_url("https://localhost:8080"));
2279        assert!(!is_public_url("https://debian.cobbler-tuna.ts.net"));
2280        assert!(!is_public_url("http://127.0.0.1:10001"));
2281        assert!(!is_public_url("http://192.168.1.10"));
2282        assert!(!is_public_url("http://[::1]"));
2283        assert!(!is_public_url("not a url"));
2284    }
2285
2286    // resolve_extra_networks positional args:
2287    // (name, enable_auth, authelia_installed, caddy_installed,
2288    //  inbucket_installed, has_url, has_smtp)
2289
2290    #[test]
2291    fn networks_empty_when_no_auth() {
2292        let nets = resolve_extra_networks("whoami", false, false, false, false, false, false);
2293        assert!(nets.is_empty());
2294    }
2295
2296    #[test]
2297    fn networks_empty_when_auth_but_no_authelia() {
2298        let nets = resolve_extra_networks("forgejo", true, false, false, false, false, false);
2299        assert!(nets.is_empty());
2300    }
2301
2302    #[test]
2303    fn networks_authelia_when_auth_enabled() {
2304        let nets = resolve_extra_networks("forgejo", true, true, false, false, false, false);
2305        assert_eq!(nets, vec!["authelia"]);
2306    }
2307
2308    #[test]
2309    fn networks_auth_with_caddy_includes_both() {
2310        let nets = resolve_extra_networks("forgejo", true, true, true, false, false, false);
2311        assert!(nets.contains(&"authelia".to_string()));
2312        assert!(nets.contains(&"caddy".to_string()));
2313    }
2314
2315    #[test]
2316    fn networks_authelia_excluded_for_authelia_itself() {
2317        let nets = resolve_extra_networks("authelia", true, true, false, false, false, false);
2318        assert!(nets.is_empty());
2319    }
2320
2321    #[test]
2322    fn networks_smtp_joins_inbucket_without_caddy() {
2323        // Reaching inbucket for SMTP must NOT require caddy.
2324        let nets = resolve_extra_networks("forgejo", false, false, false, true, false, true);
2325        assert_eq!(nets, vec!["inbucket"]);
2326    }
2327
2328    #[test]
2329    fn networks_smtp_skips_inbucket_when_it_is_self() {
2330        let nets = resolve_extra_networks("inbucket", false, false, false, true, false, true);
2331        assert!(!nets.contains(&"inbucket".to_string()));
2332    }
2333
2334    #[test]
2335    fn networks_smtp_skips_inbucket_when_not_installed() {
2336        let nets = resolve_extra_networks("forgejo", false, false, false, false, false, true);
2337        assert!(!nets.contains(&"inbucket".to_string()));
2338    }
2339
2340    #[test]
2341    fn quadlet_belongs_to_exact_match() {
2342        let all = &["foo", "foo-bar"];
2343        assert!(quadlet_belongs_to("foo.container", "foo", all));
2344        assert!(quadlet_belongs_to("foo.network", "foo", all));
2345    }
2346
2347    #[test]
2348    fn quadlet_belongs_to_sidecar() {
2349        // foo-db is a sidecar, not a separate service
2350        let all = &["foo"];
2351        assert!(quadlet_belongs_to("foo-db.volume", "foo", all));
2352    }
2353
2354    #[test]
2355    fn quadlet_belongs_to_rejects_prefix_collision() {
2356        let all = &["foo", "foo-bar"];
2357        assert!(!quadlet_belongs_to("foo-bar.container", "foo", all));
2358        assert!(!quadlet_belongs_to("foo-bar-db.volume", "foo", all));
2359    }
2360
2361    #[test]
2362    fn quadlet_belongs_to_hyphenated_service() {
2363        let all = &["foo", "foo-bar"];
2364        assert!(quadlet_belongs_to("foo-bar.container", "foo-bar", all));
2365        assert!(quadlet_belongs_to("foo-bar-db.volume", "foo-bar", all));
2366        assert!(!quadlet_belongs_to("foo.container", "foo-bar", all));
2367    }
2368}