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