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