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