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