Skip to main content

ryra_core/
lib.rs

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