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