Skip to main content

ryra_core/
configure.rs

1//! `ryra config <service>` — re-plan an installed service with a
2//! caller-supplied set of [`Overrides`] applied on top of its current
3//! recorded state.
4//!
5//! The render path is shared with [`crate::add_service`] (driven via
6//! [`crate::PlanMode::Upgrade`] so the install-time rejects don't fire and
7//! the install-only side effects stay quiet). What's new here is the
8//! *state recovery*, *change classification*, and *cross-service
9//! lifecycle handling*:
10//!
11//! 1. Load `metadata.toml` + the existing `.env` and compute the current
12//!    configuration (`enable_auth`, `enable_smtp`, `enable_backup`,
13//!    `enabled_groups`, exposure, per-secret values).
14//! 2. Apply [`Overrides`] to produce the *target* configuration.
15//! 3. Re-call `add_service` in upgrade mode with the target values, passing
16//!    the existing secrets through `pre_built_ctx` so `secret.*` and
17//!    `auth.*` are preserved verbatim (a freshly-rotated JWT key would
18//!    invalidate every active session). When auth is *being enabled*,
19//!    fresh `auth.client_id` / `auth.client_secret` are minted here and
20//!    seeded into `pre_built_ctx` so the same values flow into both the
21//!    rendered `.env` and the `register_oidc_client` step.
22//! 4. Diff the plan vs. on-disk state (reusing the upgrade diff machinery)
23//!    and classify the high-level changes via [`ConfigureChange`] so the
24//!    CLI can render them with the right colour and gate destructive
25//!    transitions behind explicit confirmation.
26//! 5. Emit lifecycle side-steps the install path normally handles only at
27//!    `PlanMode::Add` time:
28//!    - Authelia OIDC client `register` / `unregister` when `--auth` is
29//!      flipped or the URL changes on an auth-enabled service (the
30//!      `redirect_uri` is pinned to the URL at registration time).
31//!    - `TailscaleEnable` / `TailscaleDisable` when the exposure crosses
32//!      the loopback / URL / tailscale boundary.
33
34use std::collections::{BTreeMap, BTreeSet};
35use std::path::PathBuf;
36
37use crate::error::{Error, Result};
38use crate::exposure::Exposure;
39use crate::generate::GeneratedFile;
40use crate::metadata::load_metadata;
41use crate::registry::resolve::ServiceRef;
42use crate::registry::service_def::{AuthKind, EnvFormat, EnvKind};
43use crate::system::secret;
44use crate::upgrade::{DiffEntry, DiffKind, DiffResult, EnvAddition};
45use crate::{
46    AddResult, PlanMode, REGISTRY_DEFAULT, Step, WellKnownService, add_service, authelia, caddy,
47    is_service_installed, list_installed, manifest, quadlet_dir, registry, resolve_registry_dir,
48    service_home,
49};
50
51/// What the caller wants to change. Every field is "leave alone" by default;
52/// `Some(_)` means "set to this value." Two-sided enums (e.g.
53/// [`ExposureChange`]) make "remove" representable without overloading
54/// `Some("")` with a sentinel meaning.
55#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
56#[serde(default)]
57pub struct Overrides {
58    /// Change the service's exposure: a public URL, a Tailscale Service,
59    /// or loopback-only. `None` leaves the current exposure alone.
60    pub exposure: Option<ExposureChange>,
61    /// Flip per-service SMTP wiring. The global SMTP config is unchanged
62    /// either way.
63    pub smtp: Option<bool>,
64    /// Flip the backup inclusion flag for this install.
65    pub backup: Option<bool>,
66    /// Flip OIDC auth wiring. `Some(true)` registers an OIDC client with
67    /// the installed auth provider and adds OIDC env vars; `Some(false)`
68    /// unregisters and strips them.
69    pub auth: Option<bool>,
70    /// Env-group names to turn ON (members land in `.env`).
71    pub enable_groups: BTreeSet<String>,
72    /// Env-group names to turn OFF (members drop out of `.env`).
73    pub disable_groups: BTreeSet<String>,
74    /// `[[choice]]` selections to change (`choice name -> option name`).
75    /// Choices not listed keep their recorded selection.
76    pub choose: BTreeMap<String, String>,
77    /// Raw per-env overrides applied during render. Useful for changing
78    /// the value of a `prompted` env var (e.g. an admin email) without
79    /// touching anything else.
80    pub env_overrides: BTreeMap<String, String>,
81    /// Re-register this service's OIDC client with the auth provider even
82    /// though auth is already on and the URL hasn't changed. Reuses the
83    /// `client_id`/`client_secret` already in the service's `.env` (no
84    /// rotation). Used to repair a provider/consumer desync, e.g. after a
85    /// `ryra backup restore` of authelia dropped the client.
86    pub reassert_auth: bool,
87}
88
89/// Exposure transition. `Loopback` means "no public route" (the install's
90/// equivalent of dropping `--url` and `--tailscale`).
91#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
92#[serde(rename_all = "snake_case")]
93pub enum ExposureChange {
94    /// Caddy-routed public URL. Internal vs. Public is auto-classified
95    /// from the hostname by [`Exposure::from_url`].
96    Url(String),
97    /// Tailscale Service exposure. The caller must pre-derive the URL
98    /// from the system's tailnet identity (`<svc>-<host>.<tailnet>`).
99    Tailscale(String),
100    /// Loopback-only (no Caddy route, no Tailscale Service).
101    Loopback,
102}
103
104/// A single high-level change the configure run will apply. The CLI uses
105/// this for the summary banner; `is_destructive` decides whether the
106/// change requires the user to type the service name to confirm.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub enum ConfigureChange {
109    /// URL changed (or added, or removed). Covers all exposure
110    /// transitions: tailscale-on shows as `Url { to: Some(ts_url) }`.
111    Url {
112        from: Option<String>,
113        to: Option<String>,
114    },
115    /// Per-service SMTP wiring toggled.
116    Smtp { from: bool, to: bool },
117    /// Backup inclusion flag flipped.
118    Backup { from: bool, to: bool },
119    /// OIDC auth wiring toggled.
120    Auth { from: bool, to: bool },
121    /// An env-group bundle was switched on (members appended to `.env`).
122    GroupEnabled(String),
123    /// An env-group bundle was switched off (members removed from `.env`).
124    GroupDisabled(String),
125    /// A single env var's value was overridden by the user.
126    EnvOverride {
127        key: String,
128        from: Option<String>,
129        to: String,
130    },
131}
132
133impl ConfigureChange {
134    /// True when applying the change would invalidate state the user might
135    /// depend on. The CLI gates these behind explicit confirmation.
136    ///
137    /// - Removing or changing the URL detaches the existing Caddy route
138    ///   (and breaks any OAuth callback configured at the old hostname).
139    /// - Disabling auth removes the OIDC client and SSO env vars; users
140    ///   who logged in via SSO can no longer reach the service that way.
141    /// - Disabling SMTP cuts off outbound mail for the service.
142    /// - Disabling backup means future `ryra backup manual` calls skip this
143    ///   install — historical snapshots are kept on the restic repo.
144    /// - Disabling a group drops env vars the service had access to;
145    ///   features depending on them stop working until re-enabled.
146    pub fn is_destructive(&self) -> bool {
147        match self {
148            ConfigureChange::Url { from, to } => from.is_some() && from != to,
149            ConfigureChange::Smtp {
150                from: true,
151                to: false,
152            } => true,
153            ConfigureChange::Backup {
154                from: true,
155                to: false,
156            } => true,
157            ConfigureChange::Auth {
158                from: true,
159                to: false,
160            } => true,
161            ConfigureChange::GroupDisabled(_) => true,
162            ConfigureChange::Smtp { .. } => false,
163            ConfigureChange::Backup { .. } => false,
164            ConfigureChange::Auth { .. } => false,
165            ConfigureChange::GroupEnabled(_) => false,
166            ConfigureChange::EnvOverride { .. } => false,
167        }
168    }
169}
170
171/// Output of [`configure_service`].
172pub struct ConfigureResult {
173    pub service: String,
174    /// High-level transitions, in a stable order — the CLI walks this for
175    /// the human-readable summary and the destructive-change gate.
176    pub changes: Vec<ConfigureChange>,
177    /// File-level diff from the upgrade machinery. Empty `entries` means
178    /// no files differ from the current install (only metadata-level
179    /// changes like `--backup` might still be in `changes`).
180    pub diff: DiffResult,
181    /// Steps to execute. Empty when neither files nor metadata would
182    /// change (no-op configure).
183    pub steps: Vec<Step>,
184    /// True if at least one change in `changes` is destructive.
185    pub has_destructive: bool,
186}
187
188impl ConfigureResult {
189    /// True when nothing would change at all — neither files, env, nor
190    /// metadata. The CLI uses this to short-circuit "already configured
191    /// that way" without printing a confusing empty summary.
192    /// `steps` is the source of truth: [`build_configure_steps`]
193    /// returns `Vec::new()` whenever no step would run.
194    pub fn is_noop(&self) -> bool {
195        self.steps.is_empty()
196    }
197}
198
199/// One env key whose value would change when the current global config is
200/// re-rendered into an installed service.
201#[derive(Debug, Clone, PartialEq, Eq)]
202pub struct EnvKeyChange {
203    pub key: String,
204    /// On-disk value, or `None` when the key isn't present yet (the registry
205    /// or global config newly produces it).
206    pub from: Option<String>,
207    pub to: String,
208    /// True when the key name looks sensitive (password / secret / token), so
209    /// the CLI masks the value when printing the diff. Display-only.
210    pub secret: bool,
211}
212
213/// Plan for propagating the current global config into one installed
214/// service's `.env`. Pure: reads the install's state, emits steps, touches
215/// nothing.
216pub struct ServiceReconcile {
217    pub service: String,
218    /// Keys whose re-rendered value differs from what's on disk, sorted by
219    /// key. Empty when the service is already current.
220    pub changes: Vec<EnvKeyChange>,
221    /// Steps to apply: a merged `.env` write (changed keys only, every other
222    /// line preserved byte-for-byte) plus a restart. Empty when `changes` is.
223    pub steps: Vec<Step>,
224}
225
226/// Re-render an installed service against the *current* global config and
227/// surface the env keys that come out different. Unlike [`configure_service`]
228/// (which applies a user's per-service change) and
229/// [`crate::upgrade::upgrade_service`] (which never touches `.env`), this is
230/// the propagation path for "the global SMTP relay / admin email / auth
231/// provider changed, push it into the services that consume it."
232///
233/// The whole `.env` is re-rendered, but everything that's *the user's* is
234/// preserved first, so the only values that can move are driven by global
235/// config (or a registry update): generated secrets and auth credentials come
236/// back through the template context, interactively-supplied values (kind
237/// `prompted`/`required`) are recovered straight from the live `.env`, and
238/// ports are pinned. The diff is then taken over every key, and applied as a
239/// line-level merge so any key the user hand-added to `.env` survives. No
240/// hardcoded list of "which fields are global" is needed — the renderer is
241/// the single source of truth.
242pub async fn reconcile_service(service_name: &str) -> Result<ServiceReconcile> {
243    let empty = ServiceReconcile {
244        service: service_name.to_string(),
245        changes: Vec::new(),
246        steps: Vec::new(),
247    };
248    if !is_service_installed(service_name) {
249        return Err(Error::ServiceNotInstalled(service_name.to_string()));
250    }
251    let metadata = load_metadata(service_name)?
252        .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
253
254    let service_ref = if metadata.registry.is_empty() || metadata.registry == REGISTRY_DEFAULT {
255        ServiceRef::Default(service_name.to_string())
256    } else if crate::registry::resolve::is_path_like(&metadata.registry) {
257        ServiceRef::Path {
258            dir: PathBuf::from(&metadata.registry),
259            name: service_name.to_string(),
260        }
261    } else {
262        ServiceRef::Custom {
263            registry: metadata.registry.clone(),
264            service: service_name.to_string(),
265        }
266    };
267    let repo_dir = resolve_registry_dir(&service_ref).await?;
268    let reg_service = registry::find_service(&repo_dir, service_name)?;
269    let def = &reg_service.def;
270
271    let enabled_groups: BTreeSet<String> = metadata.enabled_groups.iter().cloned().collect();
272    let selected_choices = metadata.selected_choices.clone();
273
274    let env_path = service_home(service_name)?.join(".env");
275    let on_disk_text = match std::fs::read_to_string(&env_path) {
276        Ok(c) => c,
277        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
278        Err(source) => {
279            return Err(Error::FileRead {
280                path: env_path,
281                source,
282            });
283        }
284    };
285    let on_disk = parse_env_content(&on_disk_text);
286
287    // Preserve everything that's the user's, so the only diffs left are
288    // global-config (or registry) driven:
289    //   - secrets / auth credentials → recovered into the template context;
290    //   - prompted/required values → recovered straight from the live `.env`
291    //     (their value came from the user at install, not from a template);
292    //   - ports → pinned.
293    let pre_built_ctx = recover_template_ctx(service_name, def)?;
294    let mut env_overrides: BTreeMap<String, String> = BTreeMap::new();
295    let mut recover_user_input = |e: &registry::service_def::EnvVar| {
296        if matches!(e.kind, EnvKind::Prompted | EnvKind::Required)
297            && let Some(v) = on_disk.get(&e.name)
298        {
299            env_overrides.insert(e.name.clone(), v.clone());
300        }
301    };
302    for e in &def.env {
303        recover_user_input(e);
304    }
305    for g in &def.env_groups {
306        if enabled_groups.contains(&g.name) {
307            for e in &g.env {
308                recover_user_input(e);
309            }
310        }
311    }
312    for c in &def.choices {
313        let selected = selected_choices.get(&c.name).unwrap_or(&c.default);
314        if let Some(o) = c.options.iter().find(|o| &o.name == selected) {
315            for e in &o.env {
316                recover_user_input(e);
317            }
318        }
319    }
320
321    let exposure: Exposure = match metadata.url.as_deref() {
322        Some(u) => Exposure::from_url(u),
323        None => Exposure::Loopback,
324    };
325    let port_overrides = read_existing_ports(service_name)?;
326    let port_in_use = |_p: u16| false;
327    let result = add_service(crate::AddServiceParams {
328        service_name,
329        exposure: &exposure,
330        auth: match metadata.auth.clone() {
331            Some(kind) => crate::AuthChoice::Native(kind),
332            None => crate::AuthChoice::None,
333        },
334        enable_smtp: metadata.smtp_enabled,
335        enable_backup: metadata.backup_enabled,
336        env_overrides: &env_overrides,
337        enabled_groups: &enabled_groups,
338        selected_choices: &selected_choices,
339        registry_name: &metadata.registry,
340        repo_dir: &repo_dir,
341        pre_built_ctx: Some(pre_built_ctx),
342        port_in_use: &port_in_use,
343        acme_mode: None,
344        mode: PlanMode::Upgrade,
345        port_overrides: &port_overrides,
346        // `ryra config` re-renders the full `.env` and reconciles it against
347        // what's on disk itself, so it doesn't use the planner's merge.
348        existing_env_file: None,
349        allow_unset_required: false,
350    })?;
351
352    let rendered_content = result
353        .steps
354        .iter()
355        .find_map(|s| match s {
356            Step::WriteFile(f) if f.path == env_path => Some(f.content.clone()),
357            _ => None,
358        })
359        .ok_or_else(|| {
360            Error::Template(format!(
361                "{service_name}: re-render produced no .env to reconcile"
362            ))
363        })?;
364    let rendered = parse_env_content(&rendered_content);
365
366    // Diff every rendered key against disk. A key present in the render but
367    // not on disk is an addition; keys only on disk (user-added, or dropped
368    // by the registry) are never touched — the merge is append/update only.
369    let mut changes: Vec<EnvKeyChange> = Vec::new();
370    for (key, new_val) in &rendered {
371        let old = on_disk.get(key);
372        if old.map(String::as_str) != Some(new_val.as_str()) {
373            changes.push(EnvKeyChange {
374                key: key.clone(),
375                from: old.cloned(),
376                to: new_val.clone(),
377                secret: is_sensitive_key(key),
378            });
379        }
380    }
381    changes.sort_by(|a, b| a.key.cmp(&b.key));
382
383    if changes.is_empty() {
384        return Ok(empty);
385    }
386
387    let merged = merge_env_changes(&on_disk_text, &changes);
388    let steps = vec![
389        Step::WriteFile(GeneratedFile {
390            path: env_path,
391            content: merged,
392        }),
393        Step::RestartService {
394            unit: service_name.to_string(),
395        },
396    ];
397    Ok(ServiceReconcile {
398        service: service_name.to_string(),
399        changes,
400        steps,
401    })
402}
403
404/// Whether an env key's *name* looks sensitive, for display masking only.
405/// Used to decide whether to print `••••••` instead of the value in the
406/// reconcile diff. Over-masking is harmless; this never affects what's
407/// written.
408fn is_sensitive_key(key: &str) -> bool {
409    let up = key.to_ascii_uppercase();
410    ["PASSWORD", "PASSWD", "SECRET", "TOKEN", "API_KEY", "APIKEY"]
411        .iter()
412        .any(|needle| up.contains(needle))
413}
414
415/// Parse `.env` text into a key→raw-value map. Comments and blanks skipped;
416/// value keeps everything after the first `=` (values may contain `=`).
417fn parse_env_content(content: &str) -> BTreeMap<String, String> {
418    let mut out = BTreeMap::new();
419    for line in content.lines() {
420        let line = line.trim();
421        if line.is_empty() || line.starts_with('#') {
422            continue;
423        }
424        if let Some((k, v)) = line.split_once('=') {
425            out.insert(k.trim().to_string(), v.to_string());
426        }
427    }
428    out
429}
430
431/// Apply `changes` to the existing `.env` text line-by-line: rewrite the
432/// value of any changed key in place (preserving file order and comments),
433/// and append keys that weren't present. Every untouched line — secrets,
434/// ports, prompted values, user-added keys — survives verbatim.
435fn merge_env_changes(existing: &str, changes: &[EnvKeyChange]) -> String {
436    let by_key: BTreeMap<&str, &str> = changes
437        .iter()
438        .map(|c| (c.key.as_str(), c.to.as_str()))
439        .collect();
440    let mut applied: BTreeSet<&str> = BTreeSet::new();
441    let mut lines: Vec<String> = Vec::new();
442    for line in existing.lines() {
443        if let Some((k, _)) = line.trim().split_once('=') {
444            let key = k.trim();
445            if let Some(new_val) = by_key.get(key) {
446                lines.push(format!("{key}={new_val}"));
447                applied.insert(key);
448                continue;
449            }
450        }
451        lines.push(line.to_string());
452    }
453    for c in changes {
454        if !applied.contains(c.key.as_str()) {
455            lines.push(format!("{}={}", c.key, c.to));
456        }
457    }
458    let mut content = lines.join("\n");
459    content.push('\n');
460    content
461}
462
463/// Re-plan an installed service against `overrides`. Pure: emits steps but
464/// performs no I/O beyond reading the current install's state from disk.
465pub async fn configure_service(
466    service_name: &str,
467    overrides: &Overrides,
468) -> Result<ConfigureResult> {
469    if !is_service_installed(service_name) {
470        return Err(Error::ServiceNotInstalled(service_name.to_string()));
471    }
472
473    let metadata = load_metadata(service_name)?
474        .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
475
476    let current_url: Option<String> = metadata.url.clone();
477    let current_smtp: bool = metadata.smtp_enabled;
478    let current_backup: bool = metadata.backup_enabled;
479    let current_auth: bool = metadata.auth.is_some();
480    let current_groups: BTreeSet<String> = metadata.enabled_groups.iter().cloned().collect();
481    let current_choices = metadata.selected_choices.clone();
482
483    // Compute target values.
484    let target_url: Option<String> = match &overrides.exposure {
485        None => current_url.clone(),
486        Some(ExposureChange::Loopback) => None,
487        Some(ExposureChange::Url(u)) => Some(u.clone()),
488        Some(ExposureChange::Tailscale(u)) => Some(u.clone()),
489    };
490    let target_smtp: bool = overrides.smtp.unwrap_or(current_smtp);
491    let target_backup: bool = overrides.backup.unwrap_or(current_backup);
492    let target_auth: bool = overrides.auth.unwrap_or(current_auth);
493
494    let service_ref = if metadata.registry.is_empty() || metadata.registry == REGISTRY_DEFAULT {
495        ServiceRef::Default(service_name.to_string())
496    } else {
497        ServiceRef::Custom {
498            registry: metadata.registry.clone(),
499            service: service_name.to_string(),
500        }
501    };
502    let repo_dir = resolve_registry_dir(&service_ref).await?;
503    let reg_service = registry::find_service(&repo_dir, service_name)?;
504
505    // Validate env_group flags before we touch any state, mirroring
506    // `add_service`'s unknown-group check.
507    let known_groups: BTreeSet<&str> = reg_service
508        .def
509        .env_groups
510        .iter()
511        .map(|g| g.name.as_str())
512        .collect();
513    for g in overrides
514        .enable_groups
515        .iter()
516        .chain(overrides.disable_groups.iter())
517    {
518        if !known_groups.contains(g.as_str()) {
519            let known: Vec<String> = known_groups.iter().map(|s| (*s).to_string()).collect();
520            let hint = if known.is_empty() {
521                " (service defines no env_groups)".to_string()
522            } else {
523                format!(" (known: {})", known.join(", "))
524            };
525            return Err(Error::UnknownEnvGroup {
526                service: service_name.to_string(),
527                group: g.clone(),
528                hint,
529            });
530        }
531    }
532    for g in &overrides.enable_groups {
533        if overrides.disable_groups.contains(g) {
534            return Err(Error::ConfigureUnsupported {
535                service: service_name.to_string(),
536                field: format!("env_group '{g}'"),
537                workaround:
538                    "group can't appear in both --enable and --disable in one configure run"
539                        .to_string(),
540            });
541        }
542    }
543    // Validate --choose against the registry: known choice, known option.
544    for (cname, oname) in &overrides.choose {
545        let Some(choice) = reg_service.def.choices.iter().find(|c| &c.name == cname) else {
546            let known: Vec<&str> = reg_service
547                .def
548                .choices
549                .iter()
550                .map(|c| c.name.as_str())
551                .collect();
552            let hint = if known.is_empty() {
553                " (service defines no choices)".to_string()
554            } else {
555                format!(" (known: {})", known.join(", "))
556            };
557            return Err(Error::ConfigureUnsupported {
558                service: service_name.to_string(),
559                field: format!("choice '{cname}'"),
560                workaround: format!("no such choice{hint}"),
561            });
562        };
563        if !choice.options.iter().any(|o| &o.name == oname) {
564            let known: Vec<&str> = choice.options.iter().map(|o| o.name.as_str()).collect();
565            return Err(Error::ConfigureUnsupported {
566                service: service_name.to_string(),
567                field: format!("choice '{cname}' option '{oname}'"),
568                workaround: format!("no such option (known: {})", known.join(", ")),
569            });
570        }
571    }
572    if target_backup && !reg_service.def.integrations.backup {
573        return Err(Error::BackupNotSupported(service_name.to_string()));
574    }
575    // Enabling SMTP requires the service to actually consume it: an
576    // `integrations.smtp` flag *and* a `[mappings.smtp]` block to render.
577    // Mirrors the backup/auth guards (and the interactive prompt's
578    // capability gate) so `configure <svc> --smtp` on a service that can't
579    // send mail is rejected up front rather than recording a phantom
580    // `smtp_enabled = true` that renders nothing.
581    let smtp_supported =
582        reg_service.def.integrations.smtp && !reg_service.def.mappings.smtp.is_empty();
583    if !current_smtp && target_smtp && !smtp_supported {
584        return Err(Error::ConfigureUnsupported {
585            service: service_name.to_string(),
586            field: "smtp".to_string(),
587            workaround: "this service declares no SMTP support (no [mappings.smtp]); \
588                 it can't be wired to the mail relay"
589                .to_string(),
590        });
591    }
592    // Enabling auth requires the service to support OIDC natively.
593    // (`add_service` checks this too, but failing here gives a cleaner
594    // error than a half-built plan.)
595    if !current_auth
596        && target_auth
597        && reg_service.def.integrations.auth.is_empty()
598        && !crate::capability::def_provides(&reg_service.def, crate::Capability::OidcProvider)
599    {
600        return Err(Error::NoOidcSupport(service_name.to_string()));
601    }
602    // OIDC client registration needs a base URL to write into the
603    // `redirect_uris`. Covers both turn-on (need URL up front) and
604    // URL-change-while-on (the re-register would have no target).
605    let url_changed_pre = current_url != target_url;
606    let needs_register_pre = target_auth && (!current_auth || url_changed_pre);
607    if needs_register_pre && target_url.is_none() {
608        return Err(Error::ConfigureUnsupported {
609            service: service_name.to_string(),
610            field: "auth without url".to_string(),
611            workaround: "auth needs a public URL for the OIDC redirect_uri; pass `--url <URL>` \
612                 alongside `--auth`, or use `--no-auth` to disable auth"
613                .to_string(),
614        });
615    }
616
617    let mut target_groups = current_groups.clone();
618    for g in &overrides.enable_groups {
619        target_groups.insert(g.clone());
620    }
621    for g in &overrides.disable_groups {
622        target_groups.remove(g);
623    }
624
625    let mut target_choices = current_choices.clone();
626    for (cname, oname) in &overrides.choose {
627        target_choices.insert(cname.clone(), oname.clone());
628    }
629
630    // Recover existing secrets from the live `.env` so re-render doesn't
631    // mint fresh ones. When auth is being *enabled* for the first time,
632    // mint client_id / client_secret here (so we can pass the same pair
633    // to the OIDC registration step below).
634    let mut pre_built_ctx = recover_template_ctx(service_name, &reg_service.def)?;
635    let mut minted_oidc: Option<(String, String)> = None;
636    if !current_auth && target_auth {
637        let client_id = secret::generate(&EnvFormat::Uuid, None);
638        let client_secret = secret::generate(&EnvFormat::String, Some(64));
639        pre_built_ctx.insert("auth.client_id".into(), client_id.clone());
640        pre_built_ctx.insert("auth.client_secret".into(), client_secret.clone());
641        minted_oidc = Some((client_id, client_secret));
642    }
643
644    // Pin existing host ports across re-renders — same rule as upgrade.
645    let port_overrides = read_existing_ports(service_name)?;
646    let port_in_use = |_p: u16| false;
647
648    let target_exposure: Exposure = match &target_url {
649        None => Exposure::Loopback,
650        Some(u) => Exposure::from_url(u),
651    };
652    let prior_kind = current_url
653        .as_deref()
654        .map(Exposure::from_url)
655        .unwrap_or(Exposure::Loopback);
656
657    let result = add_service(crate::AddServiceParams {
658        service_name,
659        exposure: &target_exposure,
660        auth: if target_auth {
661            crate::AuthChoice::Native(AuthKind::Oidc)
662        } else {
663            crate::AuthChoice::None
664        },
665        enable_smtp: target_smtp,
666        enable_backup: target_backup,
667        env_overrides: &overrides.env_overrides,
668        enabled_groups: &target_groups,
669        selected_choices: &target_choices,
670        registry_name: &metadata.registry,
671        repo_dir: &repo_dir,
672        pre_built_ctx: Some(pre_built_ctx),
673        port_in_use: &port_in_use,
674        // ACME is only consumed when seeding caddy on first install.
675        acme_mode: None,
676        mode: PlanMode::Upgrade,
677        port_overrides: &port_overrides,
678        // `ryra config` re-renders the full `.env` and reconciles it against
679        // what's on disk itself, so it doesn't use the planner's merge.
680        existing_env_file: None,
681        allow_unset_required: false,
682    })?;
683
684    let diff = build_diff(service_name, &result)?;
685
686    // High-level changes — order reflects how a user mentally categorises
687    // the transitions (routing first, then per-service features, then
688    // env scope, then individual vars).
689    let mut changes: Vec<ConfigureChange> = Vec::new();
690    if current_url != target_url {
691        changes.push(ConfigureChange::Url {
692            from: current_url.clone(),
693            to: target_url.clone(),
694        });
695    }
696    if current_auth != target_auth {
697        changes.push(ConfigureChange::Auth {
698            from: current_auth,
699            to: target_auth,
700        });
701    }
702    if current_smtp != target_smtp {
703        changes.push(ConfigureChange::Smtp {
704            from: current_smtp,
705            to: target_smtp,
706        });
707    }
708    if current_backup != target_backup {
709        changes.push(ConfigureChange::Backup {
710            from: current_backup,
711            to: target_backup,
712        });
713    }
714    for g in target_groups.difference(&current_groups) {
715        changes.push(ConfigureChange::GroupEnabled(g.clone()));
716    }
717    for g in current_groups.difference(&target_groups) {
718        changes.push(ConfigureChange::GroupDisabled(g.clone()));
719    }
720    let existing_env = read_existing_env_keys(service_name)?;
721    for (key, val) in &overrides.env_overrides {
722        let prior = existing_env.get(key).cloned();
723        if prior.as_deref() != Some(val.as_str()) {
724            changes.push(ConfigureChange::EnvOverride {
725                key: key.clone(),
726                from: prior,
727                to: val.clone(),
728            });
729        }
730    }
731    let has_destructive = changes.iter().any(|c| c.is_destructive());
732
733    // Cross-service lifecycle: classify what side-effects this configure
734    // needs beyond writing the service's own files.
735    //
736    // OIDC: re-register whenever (a) auth is being turned on, or (b)
737    // auth was already on but the URL changed (Authelia pins the
738    // redirect_uri at registration time, so the old entry would now
739    // point at the wrong hostname).
740    let url_changed = current_url != target_url;
741    let needs_unregister = current_auth && (!target_auth || url_changed);
742    // `reassert_auth` forces a re-register (reusing the existing `.env` creds
743    // via the same path a URL change takes) without a URL change. The outer
744    // `target_auth` gate means it only fires for services that actually have
745    // auth on; a no-op otherwise.
746    let needs_register = target_auth && (!current_auth || url_changed || overrides.reassert_auth);
747    // Tailscale: enable when entering, disable when leaving. The two
748    // sides are independent — going `tailscale → url` runs both.
749    let prior_is_ts = matches!(prior_kind, Exposure::Tailscale { .. });
750    let target_is_ts = matches!(target_exposure, Exposure::Tailscale { .. });
751    let needs_tailscale_disable = prior_is_ts && !target_is_ts;
752    let needs_tailscale_enable = target_is_ts && !prior_is_ts;
753
754    // Configure is a *user-requested-change applicator*, not a
755    // drift-corrector. If the user asked for nothing (no
756    // `ConfigureChange` entries) and no cross-service lifecycle step is
757    // needed, we return zero steps — even if a `.env` re-render would
758    // produce slightly different bytes (e.g. an `{{auth.*}}` template
759    // resolving differently because caddy's port shifted since
760    // install). Drift correction is what `ryra upgrade` is for; making
761    // configure also chase drift produces confusing "nothing changed
762    // but I'm restarting your service" runs.
763    let no_user_request = changes.is_empty()
764        && !needs_unregister
765        && !needs_register
766        && !needs_tailscale_disable
767        && !needs_tailscale_enable;
768    let steps = if no_user_request {
769        Vec::new()
770    } else {
771        build_configure_steps(
772            service_name,
773            &result,
774            &reg_service.def,
775            &diff,
776            current_url.as_deref(),
777            target_url.as_deref(),
778            needs_unregister,
779            needs_register,
780            needs_tailscale_disable,
781            needs_tailscale_enable,
782            minted_oidc.as_ref(),
783        )?
784    };
785
786    Ok(ConfigureResult {
787        service: service_name.to_string(),
788        changes,
789        diff,
790        steps,
791        has_destructive,
792    })
793}
794
795/// Build the upgrade-style file diff from the freshly-planned `WriteFile`
796/// steps. Mirrors `upgrade::diff_service` but operates on the already-
797/// computed `AddResult` so we don't re-run the planner.
798fn build_diff(service_name: &str, result: &AddResult) -> Result<DiffResult> {
799    let manifest_file = manifest::manifest_path(service_name)?;
800    let (manifest_entries, _) = manifest::load(service_name)?.unwrap_or_default();
801    let manifest_by_path: BTreeMap<PathBuf, String> = manifest_entries
802        .into_iter()
803        .map(|e| (e.path, e.sha256))
804        .collect();
805
806    let planned: BTreeMap<PathBuf, String> = result
807        .steps
808        .iter()
809        .filter_map(|s| match s {
810            Step::WriteFile(f) => Some((f.path.clone(), f.content.clone())),
811            _ => None,
812        })
813        .collect();
814
815    let existing_env = read_existing_env_keys(service_name)?;
816    let env_additions: Vec<EnvAddition> = result
817        .tracked_envs
818        .iter()
819        .filter(|p| !existing_env.contains_key(&p.key))
820        .map(|p| EnvAddition {
821            key: p.key.clone(),
822            value: p.value.clone(),
823            kind: p.kind.clone(),
824            prompt: p.prompt.clone(),
825        })
826        .collect();
827
828    let mut entries: Vec<DiffEntry> = Vec::new();
829    let mut seen: BTreeSet<PathBuf> = BTreeSet::new();
830    let env_filename = std::ffi::OsStr::new(".env");
831
832    for (path, content) in &planned {
833        seen.insert(path.clone());
834        let planned_hash = manifest::hash_bytes(content.as_bytes());
835        let on_disk_hash = if path.exists() {
836            Some(manifest::hash_file(path)?)
837        } else {
838            None
839        };
840        let manifest_hash = manifest_by_path.get(path);
841        let is_env = path.file_name() == Some(env_filename);
842        let is_manifest = path == &manifest_file;
843        let kind = match (on_disk_hash.as_deref(), manifest_hash.map(String::as_str)) {
844            (None, _) => match manifest_hash {
845                Some(_) => DiffKind::Modified,
846                None => DiffKind::Added,
847            },
848            (Some(d), _) if d == planned_hash => DiffKind::Unchanged,
849            // `.env` and the manifest itself have no manifest entry by
850            // design (`.env` because of rotating secrets, the manifest
851            // because of self-reference). For both, "no manifest entry"
852            // does NOT mean drift — treat them as ryra-owned and safe
853            // to overwrite. Without this carve-out they'd always read
854            // as Drift on the first configure of a legacy install.
855            (Some(_), None) if is_env || is_manifest => DiffKind::Modified,
856            (Some(_), None) => DiffKind::Drift,
857            (Some(d), Some(l)) if d == l => DiffKind::Modified,
858            (Some(_), Some(_)) => DiffKind::Drift,
859        };
860        entries.push(DiffEntry {
861            path: path.clone(),
862            kind,
863        });
864    }
865    for path in manifest_by_path.keys() {
866        if seen.contains(path) {
867            continue;
868        }
869        entries.push(DiffEntry {
870            path: path.clone(),
871            kind: DiffKind::Removed,
872        });
873    }
874    entries.sort_by(|a, b| a.path.cmp(&b.path));
875    Ok(DiffResult {
876        service: service_name.to_string(),
877        entries,
878        env_additions,
879        // Configure diffs are about changed integration config, not native
880        // source freshness — that signal belongs to the upgrade path.
881        source_stale: false,
882    })
883}
884
885/// Assemble the final step list:
886///
887/// ```text
888///   writes → copies → removals
889///   → caddy route teardown (url leaving)
890///   → OIDC unregister (auth off / url changed with auth)
891///   → Tailscale disable (leaving tailscale)
892///   → daemon-reload (if any quadlet changed)
893///   → caddy reload (if Caddyfile changed)
894///   → Tailscale setup + enable (entering tailscale)
895///   → OIDC register (auth on / url changed with auth)
896///   → restart
897/// ```
898///
899/// `Reload/restart` steps are gated on at least one file actually changing
900/// **or** a cross-service lifecycle step needing to run. Without the gate,
901/// configure would always restart the unit (phantom downtime) and prompt
902/// the user to confirm even when there was literally nothing to apply.
903#[allow(clippy::too_many_arguments)]
904fn build_configure_steps(
905    service_name: &str,
906    result: &AddResult,
907    service_def: &registry::service_def::ServiceDef,
908    diff: &DiffResult,
909    current_url: Option<&str>,
910    target_url: Option<&str>,
911    needs_unregister: bool,
912    needs_register: bool,
913    needs_tailscale_disable: bool,
914    needs_tailscale_enable: bool,
915    minted_oidc: Option<&(String, String)>,
916) -> Result<Vec<Step>> {
917    let unchanged: BTreeSet<PathBuf> = diff
918        .entries
919        .iter()
920        .filter(|e| matches!(e.kind, DiffKind::Unchanged))
921        .map(|e| e.path.clone())
922        .collect();
923
924    let mut writes: Vec<Step> = Vec::new();
925    let mut copies: Vec<Step> = Vec::new();
926    let mut kept_caddyfile = false;
927    let mut kept_quadlet = false;
928    let caddyfile_path = caddy::caddyfile_path().ok();
929
930    let home_dir = service_home(service_name)?;
931    for step in &result.steps {
932        match step {
933            // Install-only — configure issues a Restart at the very end if needed.
934            Step::StartService { .. } => continue,
935            // Home dir already exists.
936            Step::CreateDir(p) if p == &home_dir => continue,
937            // Image pulls are idempotent and rare to need on configure.
938            Step::PullImage { .. } => continue,
939            // Defer until we know whether any write happened.
940            Step::DaemonReload | Step::ReloadCaddy | Step::Symlink { .. } => continue,
941            // Install-only Tailscale steps — configure decides via the
942            // explicit lifecycle flags below.
943            Step::TailscaleSetup | Step::TailscaleEnable { .. } | Step::TailscaleDisable { .. } => {
944                continue;
945            }
946            Step::WriteFile(file) => {
947                if unchanged.contains(&file.path) {
948                    continue;
949                }
950                if Some(&file.path) == caddyfile_path.as_ref() {
951                    kept_caddyfile = true;
952                }
953                // Quadlet files (`.container`, `.network`, `.volume`, …)
954                // live in `service_home/<name>.<ext>` and are *symlinked*
955                // into the quadlet dir. Detect by extension: the symlink
956                // in quadlet_dir is what `systemctl --user daemon-reload`
957                // picks up, but the target it points at is the write
958                // path we see here.
959                if is_quadlet_filename(&file.path) {
960                    kept_quadlet = true;
961                }
962                writes.push(Step::WriteFile(GeneratedFile {
963                    path: file.path.clone(),
964                    content: file.content.clone(),
965                }));
966            }
967            Step::CopyFile { src, dst } => {
968                copies.push(Step::CopyFile {
969                    src: src.clone(),
970                    dst: dst.clone(),
971                });
972            }
973            other => copies.push(clone_step(other)),
974        }
975    }
976
977    // Removed files: planner didn't emit them; rebuild the delete steps.
978    let mut removals: Vec<Step> = Vec::new();
979    for entry in &diff.entries {
980        if matches!(entry.kind, DiffKind::Removed) && entry.path.exists() {
981            removals.push(Step::RemoveFile(entry.path.clone()));
982        }
983    }
984
985    // Caddy route teardown: emit when configure removes the URL *or*
986    // when changing to a non-Caddy exposure (Loopback / Tailscale). The
987    // add path strips and re-adds the block atomically when the URL
988    // changes from one Caddy-routed value to another, so we only need a
989    // teardown here for the *leaving Caddy* case.
990    let prior_exp = current_url
991        .map(Exposure::from_url)
992        .unwrap_or(Exposure::Loopback);
993    let target_exp = target_url
994        .map(Exposure::from_url)
995        .unwrap_or(Exposure::Loopback);
996    let prior_caddy = matches!(
997        prior_exp,
998        Exposure::Internal { .. } | Exposure::Public { .. }
999    );
1000    let target_caddy = matches!(
1001        target_exp,
1002        Exposure::Internal { .. } | Exposure::Public { .. }
1003    );
1004    let mut url_teardown: Vec<Step> = Vec::new();
1005    if prior_caddy
1006        && !target_caddy
1007        && let Some(prev) = current_url
1008        && let Some(s) = caddy_remove_route_steps(service_name, prev)?
1009    {
1010        url_teardown = s;
1011        kept_caddyfile = true;
1012    }
1013
1014    // OIDC unregister + Tailscale disable steps run on the *old* state.
1015    let mut unregister_steps: Vec<Step> = Vec::new();
1016    if needs_unregister {
1017        unregister_steps = authelia::unregister_oidc_client(service_name)?;
1018    }
1019    let mut tailscale_disable_steps: Vec<Step> = Vec::new();
1020    if needs_tailscale_disable
1021        && let Some(svc_name) = current_url
1022            .map(Exposure::from_url)
1023            .as_ref()
1024            .and_then(|e| e.tailscale_svc_name())
1025    {
1026        tailscale_disable_steps.push(Step::TailscaleDisable { svc_name });
1027    }
1028
1029    // OIDC register + Tailscale enable steps run on the *new* state.
1030    let mut register_steps: Vec<Step> = Vec::new();
1031    if needs_register {
1032        let (client_id, client_secret) = match minted_oidc {
1033            Some((id, secret)) => (id.clone(), secret.clone()),
1034            None => {
1035                // URL change on a service that was already auth-enabled.
1036                // Reuse the existing credentials so authelia's new entry
1037                // matches whatever the service's `.env` already holds.
1038                let env = read_existing_env_keys(service_name)?;
1039                let id = service_def
1040                    .mappings
1041                    .auth
1042                    .iter()
1043                    .find(|(_, v)| v.trim() == "{{auth.client_id}}")
1044                    .and_then(|(k, _)| env.get(k).map(|v| trim_env_value(v)))
1045                    .ok_or_else(|| {
1046                        Error::AuthContext(format!(
1047                            "service '{service_name}' has auth=oidc in metadata but no \
1048                             OAUTH_CLIENT_ID-shaped env var found — cannot re-register OIDC \
1049                             client at the new URL"
1050                        ))
1051                    })?;
1052                let secret = service_def
1053                    .mappings
1054                    .auth
1055                    .iter()
1056                    .find(|(_, v)| v.trim() == "{{auth.client_secret}}")
1057                    .and_then(|(k, _)| env.get(k).map(|v| trim_env_value(v)))
1058                    .unwrap_or_default();
1059                (id, secret)
1060            }
1061        };
1062        let mut ctx: BTreeMap<String, String> = BTreeMap::new();
1063        ctx.insert("auth.client_id".into(), client_id);
1064        ctx.insert("auth.client_secret".into(), client_secret);
1065        if let Some(u) = target_url {
1066            ctx.insert("service.url".into(), u.to_string());
1067        }
1068        let qdir = quadlet_dir()?;
1069        register_steps =
1070            authelia::register_oidc_client(service_name, service_def, target_url, &ctx, &qdir)?;
1071    }
1072    let mut tailscale_enable_steps: Vec<Step> = Vec::new();
1073    if needs_tailscale_enable
1074        && let Some(svc_name) = target_url
1075            .map(Exposure::from_url)
1076            .as_ref()
1077            .and_then(|e| e.tailscale_svc_name())
1078    {
1079        let primary = result
1080            .allocated_ports
1081            .iter()
1082            .find(|(n, _)| n.eq_ignore_ascii_case("http"))
1083            .or_else(|| result.allocated_ports.first())
1084            .map(|(_, p)| *p);
1085        let ts_ports =
1086            crate::plan::tailscale_ports(&service_def.ports, &result.allocated_ports, primary);
1087        if !ts_ports.is_empty() {
1088            tailscale_enable_steps.push(Step::TailscaleSetup);
1089            tailscale_enable_steps.push(Step::TailscaleEnable {
1090                svc_name,
1091                ports: ts_ports,
1092            });
1093        }
1094    }
1095
1096    let any_file_change = !writes.is_empty() || !removals.is_empty() || !url_teardown.is_empty();
1097    let any_lifecycle = !unregister_steps.is_empty()
1098        || !register_steps.is_empty()
1099        || !tailscale_disable_steps.is_empty()
1100        || !tailscale_enable_steps.is_empty();
1101    if !any_file_change && !any_lifecycle {
1102        return Ok(Vec::new());
1103    }
1104    // Restart only when something the *container* actually sees has
1105    // changed: a quadlet rewrite, a `.env` rewrite, a script/cert
1106    // appearing or disappearing under service_home, a Caddyfile rewrite
1107    // that fronts this service, or an OIDC / Tailscale lifecycle step.
1108    // Metadata-only changes (backup_enabled, smtp_enabled flag) live in
1109    // `metadata.toml` and don't touch the running unit — restarting on
1110    // those eats systemd's `StartLimitBurst` budget for no reason.
1111    // ryra's own bookkeeping files (metadata.toml + service.manifest)
1112    // never reach the container — they're pure state for `ryra list`,
1113    // `ryra upgrade`, etc. A write that only touches these doesn't
1114    // warrant a restart.
1115    let manifest_file = manifest::manifest_path(service_name).ok();
1116    let metadata_file = manifest_file
1117        .as_ref()
1118        .and_then(|p| p.parent().map(|p| p.join("metadata.toml")));
1119    let writes_affect_runtime = writes.iter().any(|s| match s {
1120        Step::WriteFile(f) => {
1121            Some(&f.path) != metadata_file.as_ref() && Some(&f.path) != manifest_file.as_ref()
1122        }
1123        _ => false,
1124    });
1125    let needs_restart =
1126        writes_affect_runtime || !removals.is_empty() || !url_teardown.is_empty() || any_lifecycle;
1127
1128    let mut steps: Vec<Step> = Vec::new();
1129    // Forward Symlinks alongside their WriteFile pairs.
1130    for step in &result.steps {
1131        if let Step::Symlink { link, target } = step
1132            && writes
1133                .iter()
1134                .any(|s| matches!(s, Step::WriteFile(f) if &f.path == target))
1135        {
1136            steps.push(Step::Symlink {
1137                link: link.clone(),
1138                target: target.clone(),
1139            });
1140        }
1141    }
1142    steps.splice(0..0, writes);
1143    steps.extend(copies);
1144    steps.extend(removals);
1145    steps.extend(url_teardown);
1146    steps.extend(unregister_steps);
1147    steps.extend(tailscale_disable_steps);
1148    if kept_quadlet {
1149        steps.push(Step::DaemonReload);
1150    }
1151    if kept_caddyfile {
1152        steps.push(Step::ReloadCaddy);
1153    }
1154    steps.extend(tailscale_enable_steps);
1155    steps.extend(register_steps);
1156    if needs_restart {
1157        steps.push(Step::RestartService {
1158            unit: service_name.to_string(),
1159        });
1160    }
1161    Ok(steps)
1162}
1163
1164/// When configure is dropping a URL, emit the Caddyfile mutation that
1165/// strips the matching `# Service-Source: registry/<svc>` block, plus a
1166/// `ReloadCaddy` step. Returns `None` if Caddy isn't installed or no
1167/// block matches — both legitimate states for a non-Caddy-routed URL.
1168fn caddy_remove_route_steps(service_name: &str, prior_url: &str) -> Result<Option<Vec<Step>>> {
1169    use crate::{Capability, find_installed_provider};
1170    let installed = list_installed().unwrap_or_default();
1171    if find_installed_provider(&installed, Capability::ReverseProxy).is_none() {
1172        return Ok(None);
1173    }
1174    // Loopback / Tailscale never had a Caddy route — skip the rewrite.
1175    let prior_exp = Exposure::from_url(prior_url);
1176    if matches!(prior_exp, Exposure::Loopback | Exposure::Tailscale { .. }) {
1177        return Ok(None);
1178    }
1179    if WellKnownService::Caddy.matches(service_name) {
1180        return Ok(None);
1181    }
1182    let caddyfile_path = caddy::caddyfile_path()?;
1183    if !caddyfile_path.exists() {
1184        return Ok(None);
1185    }
1186    let existing = std::fs::read_to_string(&caddyfile_path).map_err(|source| Error::FileRead {
1187        path: caddyfile_path.clone(),
1188        source,
1189    })?;
1190    let updated = caddy::remove_route(&existing, service_name);
1191    if updated == existing {
1192        return Ok(None);
1193    }
1194    let mut out: Vec<Step> = Vec::new();
1195    out.push(Step::WriteFile(GeneratedFile {
1196        path: caddyfile_path,
1197        content: updated.clone(),
1198    }));
1199    if !updated.trim().is_empty() {
1200        out.push(Step::ReloadCaddy);
1201    }
1202    Ok(Some(out))
1203}
1204
1205/// Read `.env` and reconstruct the template context entries the planner
1206/// would otherwise have to regenerate. Every `KEY=VALUE` line whose `KEY`
1207/// matches one of the service's `{{secret.<name>}}` or `{{auth.<name>}}`
1208/// references seeds the context with the on-disk value, so `add_service`
1209/// (called in upgrade mode) reuses the existing credentials verbatim
1210/// instead of minting fresh ones.
1211fn recover_template_ctx(
1212    service_name: &str,
1213    def: &registry::service_def::ServiceDef,
1214) -> Result<BTreeMap<String, String>> {
1215    let existing_env = read_existing_env_keys(service_name)?;
1216    if existing_env.is_empty() {
1217        return Ok(BTreeMap::new());
1218    }
1219    let mut ctx = BTreeMap::new();
1220
1221    let collect_secrets = |value: &str, out: &mut Vec<String>| {
1222        let mut rest = value;
1223        while let Some(start) = rest.find("{{secret.") {
1224            let after = &rest[start + 9..];
1225            if let Some(end) = after.find("}}") {
1226                out.push(after[..end].to_string());
1227                rest = &after[end + 2..];
1228            } else {
1229                break;
1230            }
1231        }
1232    };
1233    let collect_auth = |value: &str, out: &mut Vec<String>| {
1234        for needle in ["{{auth.client_id", "{{auth.client_secret"] {
1235            if value.contains(needle) {
1236                let stripped = needle.trim_start_matches("{{auth.");
1237                out.push(stripped.to_string());
1238            }
1239        }
1240    };
1241
1242    let mut secret_pairs: Vec<(String, String)> = Vec::new();
1243    let mut auth_keys: Vec<String> = Vec::new();
1244
1245    let mut consider = |env: &registry::service_def::EnvVar| {
1246        let trimmed = env.value.trim();
1247        if let Some(name) = trimmed
1248            .strip_prefix("{{secret.")
1249            .and_then(|s| s.strip_suffix("}}"))
1250            && let Some(live) = existing_env.get(&env.name)
1251        {
1252            secret_pairs.push((name.to_string(), trim_env_value(live)));
1253        }
1254        let mut extras: Vec<String> = Vec::new();
1255        collect_secrets(&env.value, &mut extras);
1256        for n in extras {
1257            if !secret_pairs.iter().any(|(k, _)| k == &n) {
1258                secret_pairs.push((n, String::new()));
1259            }
1260        }
1261        let mut auth_refs: Vec<String> = Vec::new();
1262        collect_auth(&env.value, &mut auth_refs);
1263        for n in auth_refs {
1264            if !auth_keys.contains(&n) {
1265                auth_keys.push(n);
1266            }
1267        }
1268    };
1269
1270    for e in &def.env {
1271        consider(e);
1272    }
1273    for g in &def.env_groups {
1274        for e in &g.env {
1275            consider(e);
1276        }
1277    }
1278    for (env_name, value_template) in &def.mappings.auth {
1279        let env = registry::service_def::EnvVar {
1280            name: env_name.clone(),
1281            value: value_template.clone(),
1282            kind: Default::default(),
1283            prompt: None,
1284            format: Default::default(),
1285            length: None,
1286            jwt_claims: None,
1287            jwt_signing_key: None,
1288        };
1289        consider(&env);
1290    }
1291
1292    for (name, value) in &secret_pairs {
1293        if !value.is_empty() {
1294            ctx.insert(format!("secret.{name}"), value.clone());
1295        }
1296    }
1297    for (env_name, value_template) in &def.mappings.auth {
1298        let trimmed = value_template.trim();
1299        if let Some(rest) = trimmed
1300            .strip_prefix("{{auth.")
1301            .and_then(|s| s.strip_suffix("}}"))
1302            && let Some(live) = existing_env.get(env_name)
1303        {
1304            ctx.insert(format!("auth.{rest}"), trim_env_value(live));
1305        }
1306    }
1307
1308    Ok(ctx)
1309}
1310
1311fn trim_env_value(raw: &str) -> String {
1312    raw.trim_matches(|c: char| c == '"' || c == '\'')
1313        .to_string()
1314}
1315
1316/// True when `path`'s filename ends in a podman-quadlet extension. Quadlet
1317/// regenerates a `.service` per matching file on every
1318/// `systemctl --user daemon-reload`, so a write to any of these means we
1319/// need to emit a reload before restarting.
1320fn is_quadlet_filename(path: &std::path::Path) -> bool {
1321    matches!(
1322        path.extension().and_then(|e| e.to_str()),
1323        Some("container" | "volume" | "network" | "kube" | "image" | "pod" | "build")
1324    )
1325}
1326
1327/// Parse the on-disk `.env` for a service into a key→value map.
1328fn read_existing_env_keys(service_name: &str) -> Result<BTreeMap<String, String>> {
1329    let env_path = service_home(service_name)?.join(".env");
1330    let mut out: BTreeMap<String, String> = BTreeMap::new();
1331    let content = match std::fs::read_to_string(&env_path) {
1332        Ok(c) => c,
1333        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
1334        Err(source) => {
1335            return Err(Error::FileRead {
1336                path: env_path,
1337                source,
1338            });
1339        }
1340    };
1341    for line in content.lines() {
1342        let line = line.trim();
1343        if line.is_empty() || line.starts_with('#') {
1344            continue;
1345        }
1346        if let Some((k, v)) = line.split_once('=') {
1347            out.insert(k.trim().to_string(), v.to_string());
1348        }
1349    }
1350    Ok(out)
1351}
1352
1353/// Pin existing host ports across re-renders.
1354fn read_existing_ports(service_name: &str) -> Result<BTreeMap<String, u16>> {
1355    let env_path = service_home(service_name)?.join(".env");
1356    let mut overrides = BTreeMap::new();
1357    let content = match std::fs::read_to_string(&env_path) {
1358        Ok(c) => c,
1359        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(overrides),
1360        Err(source) => {
1361            return Err(Error::FileRead {
1362                path: env_path,
1363                source,
1364            });
1365        }
1366    };
1367    for line in content.lines() {
1368        let line = line.trim();
1369        if line.is_empty() || line.starts_with('#') {
1370            continue;
1371        }
1372        let Some((key, value)) = line.split_once('=') else {
1373            continue;
1374        };
1375        let Some(name) = key.strip_prefix("SERVICE_PORT_") else {
1376            continue;
1377        };
1378        if let Ok(port) = value.trim().parse::<u16>() {
1379            overrides.insert(name.to_ascii_lowercase(), port);
1380        }
1381    }
1382    Ok(overrides)
1383}
1384
1385/// Clone a `Step` explicitly. `Step` carries non-`Clone` payloads in
1386/// places; we list each variant so a new one forces a compile error
1387/// here rather than silently being dropped.
1388fn clone_step(step: &Step) -> Step {
1389    match step {
1390        Step::WriteFile(f) => Step::WriteFile(GeneratedFile {
1391            path: f.path.clone(),
1392            content: f.content.clone(),
1393        }),
1394        Step::Symlink { link, target } => Step::Symlink {
1395            link: link.clone(),
1396            target: target.clone(),
1397        },
1398        Step::DaemonReload => Step::DaemonReload,
1399        Step::StartService { unit } => Step::StartService { unit: unit.clone() },
1400        Step::EnableService { unit } => Step::EnableService { unit: unit.clone() },
1401        Step::DisableService { unit } => Step::DisableService { unit: unit.clone() },
1402        Step::StopService { unit } => Step::StopService { unit: unit.clone() },
1403        Step::RestartService { unit } => Step::RestartService { unit: unit.clone() },
1404        Step::ReloadCaddy => Step::ReloadCaddy,
1405        Step::PullImage { image } => Step::PullImage {
1406            image: image.clone(),
1407        },
1408        Step::RemoveFile(p) => Step::RemoveFile(p.clone()),
1409        Step::RemoveDir(p) => Step::RemoveDir(p.clone()),
1410        Step::RemoveVolume { name } => Step::RemoveVolume { name: name.clone() },
1411        Step::RemoveNetwork { name } => Step::RemoveNetwork { name: name.clone() },
1412        Step::CreateDir(p) => Step::CreateDir(p.clone()),
1413        Step::WaitForFile { path, timeout_secs } => Step::WaitForFile {
1414            path: path.clone(),
1415            timeout_secs: *timeout_secs,
1416        },
1417        Step::WaitForHttpHealthy {
1418            url,
1419            expect_status,
1420            timeout_secs,
1421        } => Step::WaitForHttpHealthy {
1422            url: url.clone(),
1423            expect_status: *expect_status,
1424            timeout_secs: *timeout_secs,
1425        },
1426        Step::CopyFile { src, dst } => Step::CopyFile {
1427            src: src.clone(),
1428            dst: dst.clone(),
1429        },
1430        Step::Build { dir, command } => Step::Build {
1431            dir: dir.clone(),
1432            command: command.clone(),
1433        },
1434        Step::SyncDir { src, dst } => Step::SyncDir {
1435            src: src.clone(),
1436            dst: dst.clone(),
1437        },
1438        Step::TailscaleSetup => Step::TailscaleSetup,
1439        Step::TailscaleEnable { svc_name, ports } => Step::TailscaleEnable {
1440            svc_name: svc_name.clone(),
1441            ports: ports.clone(),
1442        },
1443        Step::TailscaleDisable { svc_name } => Step::TailscaleDisable {
1444            svc_name: svc_name.clone(),
1445        },
1446    }
1447}
1448
1449#[cfg(test)]
1450mod tests {
1451    use super::*;
1452
1453    /// The line-level merge is the safety contract for reconcile: it must
1454    /// rewrite only the changed keys and leave everything else — comments,
1455    /// secrets, ports, user-added keys, file order — byte-for-byte intact.
1456    #[test]
1457    fn merge_rewrites_only_changed_keys() {
1458        let existing = "\
1459# generated by ryra
1460SMTP_HOST=old.example.com
1461SMTP_PORT=587
1462POSTGRES_PASSWORD=s3cret-unchanged
1463ADMIN_EMAIL=me@example.com
1464SERVICE_PORT_HTTP=8080
1465USER_ADDED=keep-me
1466";
1467        let changes = vec![
1468            EnvKeyChange {
1469                key: "SMTP_HOST".into(),
1470                from: Some("old.example.com".into()),
1471                to: "new.example.com".into(),
1472                secret: false,
1473            },
1474            // A key not yet present — appended, never inserted mid-file.
1475            EnvKeyChange {
1476                key: "SMTP_FROM".into(),
1477                from: None,
1478                to: "noreply@new.example.com".into(),
1479                secret: false,
1480            },
1481        ];
1482        let merged = merge_env_changes(existing, &changes);
1483        let parsed = parse_env_content(&merged);
1484        assert_eq!(
1485            parsed.get("SMTP_HOST").map(String::as_str),
1486            Some("new.example.com")
1487        );
1488        assert_eq!(
1489            parsed.get("SMTP_FROM").map(String::as_str),
1490            Some("noreply@new.example.com")
1491        );
1492        // Untouched lines survive verbatim.
1493        assert_eq!(
1494            parsed.get("POSTGRES_PASSWORD").map(String::as_str),
1495            Some("s3cret-unchanged")
1496        );
1497        assert_eq!(
1498            parsed.get("USER_ADDED").map(String::as_str),
1499            Some("keep-me")
1500        );
1501        assert_eq!(
1502            parsed.get("SERVICE_PORT_HTTP").map(String::as_str),
1503            Some("8080")
1504        );
1505        // The comment header is preserved.
1506        assert!(merged.starts_with("# generated by ryra\n"));
1507        // No duplicate SMTP_HOST line was appended.
1508        assert_eq!(merged.matches("SMTP_HOST=").count(), 1);
1509    }
1510
1511    /// The is_destructive matrix is the safety contract: it decides
1512    /// whether the CLI demands typed confirmation. One table-driven test
1513    /// makes it cheap to spot a regression in any cell.
1514    #[test]
1515    fn destructive_classification() {
1516        let url = |from: Option<&str>, to: Option<&str>| ConfigureChange::Url {
1517            from: from.map(str::to_string),
1518            to: to.map(str::to_string),
1519        };
1520        let cases: &[(ConfigureChange, bool)] = &[
1521            // URL: changing or removing destroys old routes / OAuth callbacks.
1522            (url(Some("https://old"), Some("https://new")), true),
1523            (url(Some("https://old"), None), true),
1524            (url(None, Some("https://new")), false),
1525            (url(Some("https://x"), Some("https://x")), false),
1526            // Toggles: only the off direction is destructive.
1527            (
1528                ConfigureChange::Smtp {
1529                    from: true,
1530                    to: false,
1531                },
1532                true,
1533            ),
1534            (
1535                ConfigureChange::Smtp {
1536                    from: false,
1537                    to: true,
1538                },
1539                false,
1540            ),
1541            (
1542                ConfigureChange::Backup {
1543                    from: true,
1544                    to: false,
1545                },
1546                true,
1547            ),
1548            (
1549                ConfigureChange::Backup {
1550                    from: false,
1551                    to: true,
1552                },
1553                false,
1554            ),
1555            (
1556                ConfigureChange::Auth {
1557                    from: true,
1558                    to: false,
1559                },
1560                true,
1561            ),
1562            (
1563                ConfigureChange::Auth {
1564                    from: false,
1565                    to: true,
1566                },
1567                false,
1568            ),
1569            // Group disable drops env vars; enable just adds them.
1570            (ConfigureChange::GroupDisabled("oauth".into()), true),
1571            (ConfigureChange::GroupEnabled("oauth".into()), false),
1572            // Explicit user override: never a surprise.
1573            (
1574                ConfigureChange::EnvOverride {
1575                    key: "ADMIN_EMAIL".into(),
1576                    from: Some("a".into()),
1577                    to: "b".into(),
1578                },
1579                false,
1580            ),
1581        ];
1582        for (change, expected) in cases {
1583            assert_eq!(
1584                change.is_destructive(),
1585                *expected,
1586                "wrong classification for {change:?}"
1587            );
1588        }
1589    }
1590}