Skip to main content

ryra_core/
configure.rs

1//! `ryra configure <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 run` 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    })?;
347
348    let rendered_content = result
349        .steps
350        .iter()
351        .find_map(|s| match s {
352            Step::WriteFile(f) if f.path == env_path => Some(f.content.clone()),
353            _ => None,
354        })
355        .ok_or_else(|| {
356            Error::Template(format!(
357                "{service_name}: re-render produced no .env to reconcile"
358            ))
359        })?;
360    let rendered = parse_env_content(&rendered_content);
361
362    // Diff every rendered key against disk. A key present in the render but
363    // not on disk is an addition; keys only on disk (user-added, or dropped
364    // by the registry) are never touched — the merge is append/update only.
365    let mut changes: Vec<EnvKeyChange> = Vec::new();
366    for (key, new_val) in &rendered {
367        let old = on_disk.get(key);
368        if old.map(String::as_str) != Some(new_val.as_str()) {
369            changes.push(EnvKeyChange {
370                key: key.clone(),
371                from: old.cloned(),
372                to: new_val.clone(),
373                secret: is_sensitive_key(key),
374            });
375        }
376    }
377    changes.sort_by(|a, b| a.key.cmp(&b.key));
378
379    if changes.is_empty() {
380        return Ok(empty);
381    }
382
383    let merged = merge_env_changes(&on_disk_text, &changes);
384    let steps = vec![
385        Step::WriteFile(GeneratedFile {
386            path: env_path,
387            content: merged,
388        }),
389        Step::RestartService {
390            unit: service_name.to_string(),
391        },
392    ];
393    Ok(ServiceReconcile {
394        service: service_name.to_string(),
395        changes,
396        steps,
397    })
398}
399
400/// Whether an env key's *name* looks sensitive, for display masking only.
401/// Used to decide whether to print `••••••` instead of the value in the
402/// reconcile diff. Over-masking is harmless; this never affects what's
403/// written.
404fn is_sensitive_key(key: &str) -> bool {
405    let up = key.to_ascii_uppercase();
406    ["PASSWORD", "PASSWD", "SECRET", "TOKEN", "API_KEY", "APIKEY"]
407        .iter()
408        .any(|needle| up.contains(needle))
409}
410
411/// Parse `.env` text into a key→raw-value map. Comments and blanks skipped;
412/// value keeps everything after the first `=` (values may contain `=`).
413fn parse_env_content(content: &str) -> BTreeMap<String, String> {
414    let mut out = BTreeMap::new();
415    for line in content.lines() {
416        let line = line.trim();
417        if line.is_empty() || line.starts_with('#') {
418            continue;
419        }
420        if let Some((k, v)) = line.split_once('=') {
421            out.insert(k.trim().to_string(), v.to_string());
422        }
423    }
424    out
425}
426
427/// Apply `changes` to the existing `.env` text line-by-line: rewrite the
428/// value of any changed key in place (preserving file order and comments),
429/// and append keys that weren't present. Every untouched line — secrets,
430/// ports, prompted values, user-added keys — survives verbatim.
431fn merge_env_changes(existing: &str, changes: &[EnvKeyChange]) -> String {
432    let by_key: BTreeMap<&str, &str> = changes
433        .iter()
434        .map(|c| (c.key.as_str(), c.to.as_str()))
435        .collect();
436    let mut applied: BTreeSet<&str> = BTreeSet::new();
437    let mut lines: Vec<String> = Vec::new();
438    for line in existing.lines() {
439        if let Some((k, _)) = line.trim().split_once('=') {
440            let key = k.trim();
441            if let Some(new_val) = by_key.get(key) {
442                lines.push(format!("{key}={new_val}"));
443                applied.insert(key);
444                continue;
445            }
446        }
447        lines.push(line.to_string());
448    }
449    for c in changes {
450        if !applied.contains(c.key.as_str()) {
451            lines.push(format!("{}={}", c.key, c.to));
452        }
453    }
454    let mut content = lines.join("\n");
455    content.push('\n');
456    content
457}
458
459/// Re-plan an installed service against `overrides`. Pure: emits steps but
460/// performs no I/O beyond reading the current install's state from disk.
461pub async fn configure_service(
462    service_name: &str,
463    overrides: &Overrides,
464) -> Result<ConfigureResult> {
465    if !is_service_installed(service_name) {
466        return Err(Error::ServiceNotInstalled(service_name.to_string()));
467    }
468
469    let metadata = load_metadata(service_name)?
470        .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
471
472    let current_url: Option<String> = metadata.url.clone();
473    let current_smtp: bool = metadata.smtp_enabled;
474    let current_backup: bool = metadata.backup_enabled;
475    let current_auth: bool = metadata.auth.is_some();
476    let current_groups: BTreeSet<String> = metadata.enabled_groups.iter().cloned().collect();
477    let current_choices = metadata.selected_choices.clone();
478
479    // Compute target values.
480    let target_url: Option<String> = match &overrides.exposure {
481        None => current_url.clone(),
482        Some(ExposureChange::Loopback) => None,
483        Some(ExposureChange::Url(u)) => Some(u.clone()),
484        Some(ExposureChange::Tailscale(u)) => Some(u.clone()),
485    };
486    let target_smtp: bool = overrides.smtp.unwrap_or(current_smtp);
487    let target_backup: bool = overrides.backup.unwrap_or(current_backup);
488    let target_auth: bool = overrides.auth.unwrap_or(current_auth);
489
490    let service_ref = if metadata.registry.is_empty() || metadata.registry == REGISTRY_DEFAULT {
491        ServiceRef::Default(service_name.to_string())
492    } else {
493        ServiceRef::Custom {
494            registry: metadata.registry.clone(),
495            service: service_name.to_string(),
496        }
497    };
498    let repo_dir = resolve_registry_dir(&service_ref).await?;
499    let reg_service = registry::find_service(&repo_dir, service_name)?;
500
501    // Validate env_group flags before we touch any state, mirroring
502    // `add_service`'s unknown-group check.
503    let known_groups: BTreeSet<&str> = reg_service
504        .def
505        .env_groups
506        .iter()
507        .map(|g| g.name.as_str())
508        .collect();
509    for g in overrides
510        .enable_groups
511        .iter()
512        .chain(overrides.disable_groups.iter())
513    {
514        if !known_groups.contains(g.as_str()) {
515            let known: Vec<String> = known_groups.iter().map(|s| (*s).to_string()).collect();
516            let hint = if known.is_empty() {
517                " (service defines no env_groups)".to_string()
518            } else {
519                format!(" (known: {})", known.join(", "))
520            };
521            return Err(Error::UnknownEnvGroup {
522                service: service_name.to_string(),
523                group: g.clone(),
524                hint,
525            });
526        }
527    }
528    for g in &overrides.enable_groups {
529        if overrides.disable_groups.contains(g) {
530            return Err(Error::ConfigureUnsupported {
531                service: service_name.to_string(),
532                field: format!("env_group '{g}'"),
533                workaround:
534                    "group can't appear in both --enable and --disable in one configure run"
535                        .to_string(),
536            });
537        }
538    }
539    // Validate --choose against the registry: known choice, known option.
540    for (cname, oname) in &overrides.choose {
541        let Some(choice) = reg_service.def.choices.iter().find(|c| &c.name == cname) else {
542            let known: Vec<&str> = reg_service
543                .def
544                .choices
545                .iter()
546                .map(|c| c.name.as_str())
547                .collect();
548            let hint = if known.is_empty() {
549                " (service defines no choices)".to_string()
550            } else {
551                format!(" (known: {})", known.join(", "))
552            };
553            return Err(Error::ConfigureUnsupported {
554                service: service_name.to_string(),
555                field: format!("choice '{cname}'"),
556                workaround: format!("no such choice{hint}"),
557            });
558        };
559        if !choice.options.iter().any(|o| &o.name == oname) {
560            let known: Vec<&str> = choice.options.iter().map(|o| o.name.as_str()).collect();
561            return Err(Error::ConfigureUnsupported {
562                service: service_name.to_string(),
563                field: format!("choice '{cname}' option '{oname}'"),
564                workaround: format!("no such option (known: {})", known.join(", ")),
565            });
566        }
567    }
568    if target_backup && !reg_service.def.integrations.backup {
569        return Err(Error::BackupNotSupported(service_name.to_string()));
570    }
571    // Enabling SMTP requires the service to actually consume it: an
572    // `integrations.smtp` flag *and* a `[mappings.smtp]` block to render.
573    // Mirrors the backup/auth guards (and the interactive prompt's
574    // capability gate) so `configure <svc> --smtp` on a service that can't
575    // send mail is rejected up front rather than recording a phantom
576    // `smtp_enabled = true` that renders nothing.
577    let smtp_supported =
578        reg_service.def.integrations.smtp && !reg_service.def.mappings.smtp.is_empty();
579    if !current_smtp && target_smtp && !smtp_supported {
580        return Err(Error::ConfigureUnsupported {
581            service: service_name.to_string(),
582            field: "smtp".to_string(),
583            workaround: "this service declares no SMTP support (no [mappings.smtp]); \
584                 it can't be wired to the mail relay"
585                .to_string(),
586        });
587    }
588    // Enabling auth requires the service to support OIDC natively.
589    // (`add_service` checks this too, but failing here gives a cleaner
590    // error than a half-built plan.)
591    if !current_auth
592        && target_auth
593        && reg_service.def.integrations.auth.is_empty()
594        && !crate::capability::def_provides(&reg_service.def, crate::Capability::OidcProvider)
595    {
596        return Err(Error::NoOidcSupport(service_name.to_string()));
597    }
598    // OIDC client registration needs a base URL to write into the
599    // `redirect_uris`. Covers both turn-on (need URL up front) and
600    // URL-change-while-on (the re-register would have no target).
601    let url_changed_pre = current_url != target_url;
602    let needs_register_pre = target_auth && (!current_auth || url_changed_pre);
603    if needs_register_pre && target_url.is_none() {
604        return Err(Error::ConfigureUnsupported {
605            service: service_name.to_string(),
606            field: "auth without url".to_string(),
607            workaround: "auth needs a public URL for the OIDC redirect_uri; pass `--url <URL>` \
608                 alongside `--auth`, or use `--no-auth` to disable auth"
609                .to_string(),
610        });
611    }
612
613    let mut target_groups = current_groups.clone();
614    for g in &overrides.enable_groups {
615        target_groups.insert(g.clone());
616    }
617    for g in &overrides.disable_groups {
618        target_groups.remove(g);
619    }
620
621    let mut target_choices = current_choices.clone();
622    for (cname, oname) in &overrides.choose {
623        target_choices.insert(cname.clone(), oname.clone());
624    }
625
626    // Recover existing secrets from the live `.env` so re-render doesn't
627    // mint fresh ones. When auth is being *enabled* for the first time,
628    // mint client_id / client_secret here (so we can pass the same pair
629    // to the OIDC registration step below).
630    let mut pre_built_ctx = recover_template_ctx(service_name, &reg_service.def)?;
631    let mut minted_oidc: Option<(String, String)> = None;
632    if !current_auth && target_auth {
633        let client_id = secret::generate(&EnvFormat::Uuid, None);
634        let client_secret = secret::generate(&EnvFormat::String, Some(64));
635        pre_built_ctx.insert("auth.client_id".into(), client_id.clone());
636        pre_built_ctx.insert("auth.client_secret".into(), client_secret.clone());
637        minted_oidc = Some((client_id, client_secret));
638    }
639
640    // Pin existing host ports across re-renders — same rule as upgrade.
641    let port_overrides = read_existing_ports(service_name)?;
642    let port_in_use = |_p: u16| false;
643
644    let target_exposure: Exposure = match &target_url {
645        None => Exposure::Loopback,
646        Some(u) => Exposure::from_url(u),
647    };
648    let prior_kind = current_url
649        .as_deref()
650        .map(Exposure::from_url)
651        .unwrap_or(Exposure::Loopback);
652
653    let result = add_service(crate::AddServiceParams {
654        service_name,
655        exposure: &target_exposure,
656        auth: if target_auth {
657            crate::AuthChoice::Native(AuthKind::Oidc)
658        } else {
659            crate::AuthChoice::None
660        },
661        enable_smtp: target_smtp,
662        enable_backup: target_backup,
663        env_overrides: &overrides.env_overrides,
664        enabled_groups: &target_groups,
665        selected_choices: &target_choices,
666        registry_name: &metadata.registry,
667        repo_dir: &repo_dir,
668        pre_built_ctx: Some(pre_built_ctx),
669        port_in_use: &port_in_use,
670        // ACME is only consumed when seeding caddy on first install.
671        acme_mode: None,
672        mode: PlanMode::Upgrade,
673        port_overrides: &port_overrides,
674    })?;
675
676    let diff = build_diff(service_name, &result)?;
677
678    // High-level changes — order reflects how a user mentally categorises
679    // the transitions (routing first, then per-service features, then
680    // env scope, then individual vars).
681    let mut changes: Vec<ConfigureChange> = Vec::new();
682    if current_url != target_url {
683        changes.push(ConfigureChange::Url {
684            from: current_url.clone(),
685            to: target_url.clone(),
686        });
687    }
688    if current_auth != target_auth {
689        changes.push(ConfigureChange::Auth {
690            from: current_auth,
691            to: target_auth,
692        });
693    }
694    if current_smtp != target_smtp {
695        changes.push(ConfigureChange::Smtp {
696            from: current_smtp,
697            to: target_smtp,
698        });
699    }
700    if current_backup != target_backup {
701        changes.push(ConfigureChange::Backup {
702            from: current_backup,
703            to: target_backup,
704        });
705    }
706    for g in target_groups.difference(&current_groups) {
707        changes.push(ConfigureChange::GroupEnabled(g.clone()));
708    }
709    for g in current_groups.difference(&target_groups) {
710        changes.push(ConfigureChange::GroupDisabled(g.clone()));
711    }
712    let existing_env = read_existing_env_keys(service_name)?;
713    for (key, val) in &overrides.env_overrides {
714        let prior = existing_env.get(key).cloned();
715        if prior.as_deref() != Some(val.as_str()) {
716            changes.push(ConfigureChange::EnvOverride {
717                key: key.clone(),
718                from: prior,
719                to: val.clone(),
720            });
721        }
722    }
723    let has_destructive = changes.iter().any(|c| c.is_destructive());
724
725    // Cross-service lifecycle: classify what side-effects this configure
726    // needs beyond writing the service's own files.
727    //
728    // OIDC: re-register whenever (a) auth is being turned on, or (b)
729    // auth was already on but the URL changed (Authelia pins the
730    // redirect_uri at registration time, so the old entry would now
731    // point at the wrong hostname).
732    let url_changed = current_url != target_url;
733    let needs_unregister = current_auth && (!target_auth || url_changed);
734    // `reassert_auth` forces a re-register (reusing the existing `.env` creds
735    // via the same path a URL change takes) without a URL change. The outer
736    // `target_auth` gate means it only fires for services that actually have
737    // auth on; a no-op otherwise.
738    let needs_register = target_auth && (!current_auth || url_changed || overrides.reassert_auth);
739    // Tailscale: enable when entering, disable when leaving. The two
740    // sides are independent — going `tailscale → url` runs both.
741    let prior_is_ts = matches!(prior_kind, Exposure::Tailscale { .. });
742    let target_is_ts = matches!(target_exposure, Exposure::Tailscale { .. });
743    let needs_tailscale_disable = prior_is_ts && !target_is_ts;
744    let needs_tailscale_enable = target_is_ts && !prior_is_ts;
745
746    // Configure is a *user-requested-change applicator*, not a
747    // drift-corrector. If the user asked for nothing (no
748    // `ConfigureChange` entries) and no cross-service lifecycle step is
749    // needed, we return zero steps — even if a `.env` re-render would
750    // produce slightly different bytes (e.g. an `{{auth.*}}` template
751    // resolving differently because caddy's port shifted since
752    // install). Drift correction is what `ryra upgrade` is for; making
753    // configure also chase drift produces confusing "nothing changed
754    // but I'm restarting your service" runs.
755    let no_user_request = changes.is_empty()
756        && !needs_unregister
757        && !needs_register
758        && !needs_tailscale_disable
759        && !needs_tailscale_enable;
760    let steps = if no_user_request {
761        Vec::new()
762    } else {
763        build_configure_steps(
764            service_name,
765            &result,
766            &reg_service.def,
767            &diff,
768            current_url.as_deref(),
769            target_url.as_deref(),
770            needs_unregister,
771            needs_register,
772            needs_tailscale_disable,
773            needs_tailscale_enable,
774            minted_oidc.as_ref(),
775        )?
776    };
777
778    Ok(ConfigureResult {
779        service: service_name.to_string(),
780        changes,
781        diff,
782        steps,
783        has_destructive,
784    })
785}
786
787/// Build the upgrade-style file diff from the freshly-planned `WriteFile`
788/// steps. Mirrors `upgrade::diff_service` but operates on the already-
789/// computed `AddResult` so we don't re-run the planner.
790fn build_diff(service_name: &str, result: &AddResult) -> Result<DiffResult> {
791    let manifest_file = manifest::manifest_path(service_name)?;
792    let (manifest_entries, _) = manifest::load(service_name)?.unwrap_or_default();
793    let manifest_by_path: BTreeMap<PathBuf, String> = manifest_entries
794        .into_iter()
795        .map(|e| (e.path, e.sha256))
796        .collect();
797
798    let planned: BTreeMap<PathBuf, String> = result
799        .steps
800        .iter()
801        .filter_map(|s| match s {
802            Step::WriteFile(f) => Some((f.path.clone(), f.content.clone())),
803            _ => None,
804        })
805        .collect();
806
807    let existing_env = read_existing_env_keys(service_name)?;
808    let env_additions: Vec<EnvAddition> = result
809        .tracked_envs
810        .iter()
811        .filter(|p| !existing_env.contains_key(&p.key))
812        .map(|p| EnvAddition {
813            key: p.key.clone(),
814            value: p.value.clone(),
815            kind: p.kind.clone(),
816            prompt: p.prompt.clone(),
817        })
818        .collect();
819
820    let mut entries: Vec<DiffEntry> = Vec::new();
821    let mut seen: BTreeSet<PathBuf> = BTreeSet::new();
822    let env_filename = std::ffi::OsStr::new(".env");
823
824    for (path, content) in &planned {
825        seen.insert(path.clone());
826        let planned_hash = manifest::hash_bytes(content.as_bytes());
827        let on_disk_hash = if path.exists() {
828            Some(manifest::hash_file(path)?)
829        } else {
830            None
831        };
832        let manifest_hash = manifest_by_path.get(path);
833        let is_env = path.file_name() == Some(env_filename);
834        let is_manifest = path == &manifest_file;
835        let kind = match (on_disk_hash.as_deref(), manifest_hash.map(String::as_str)) {
836            (None, _) => match manifest_hash {
837                Some(_) => DiffKind::Modified,
838                None => DiffKind::Added,
839            },
840            (Some(d), _) if d == planned_hash => DiffKind::Unchanged,
841            // `.env` and the manifest itself have no manifest entry by
842            // design (`.env` because of rotating secrets, the manifest
843            // because of self-reference). For both, "no manifest entry"
844            // does NOT mean drift — treat them as ryra-owned and safe
845            // to overwrite. Without this carve-out they'd always read
846            // as Drift on the first configure of a legacy install.
847            (Some(_), None) if is_env || is_manifest => DiffKind::Modified,
848            (Some(_), None) => DiffKind::Drift,
849            (Some(d), Some(l)) if d == l => DiffKind::Modified,
850            (Some(_), Some(_)) => DiffKind::Drift,
851        };
852        entries.push(DiffEntry {
853            path: path.clone(),
854            kind,
855        });
856    }
857    for path in manifest_by_path.keys() {
858        if seen.contains(path) {
859            continue;
860        }
861        entries.push(DiffEntry {
862            path: path.clone(),
863            kind: DiffKind::Removed,
864        });
865    }
866    entries.sort_by(|a, b| a.path.cmp(&b.path));
867    Ok(DiffResult {
868        service: service_name.to_string(),
869        entries,
870        env_additions,
871        // Configure diffs are about changed integration config, not native
872        // source freshness — that signal belongs to the upgrade path.
873        source_stale: false,
874    })
875}
876
877/// Assemble the final step list:
878///
879/// ```text
880///   writes → copies → removals
881///   → caddy route teardown (url leaving)
882///   → OIDC unregister (auth off / url changed with auth)
883///   → Tailscale disable (leaving tailscale)
884///   → daemon-reload (if any quadlet changed)
885///   → caddy reload (if Caddyfile changed)
886///   → Tailscale setup + enable (entering tailscale)
887///   → OIDC register (auth on / url changed with auth)
888///   → restart
889/// ```
890///
891/// `Reload/restart` steps are gated on at least one file actually changing
892/// **or** a cross-service lifecycle step needing to run. Without the gate,
893/// configure would always restart the unit (phantom downtime) and prompt
894/// the user to confirm even when there was literally nothing to apply.
895#[allow(clippy::too_many_arguments)]
896fn build_configure_steps(
897    service_name: &str,
898    result: &AddResult,
899    service_def: &registry::service_def::ServiceDef,
900    diff: &DiffResult,
901    current_url: Option<&str>,
902    target_url: Option<&str>,
903    needs_unregister: bool,
904    needs_register: bool,
905    needs_tailscale_disable: bool,
906    needs_tailscale_enable: bool,
907    minted_oidc: Option<&(String, String)>,
908) -> Result<Vec<Step>> {
909    let unchanged: BTreeSet<PathBuf> = diff
910        .entries
911        .iter()
912        .filter(|e| matches!(e.kind, DiffKind::Unchanged))
913        .map(|e| e.path.clone())
914        .collect();
915
916    let mut writes: Vec<Step> = Vec::new();
917    let mut copies: Vec<Step> = Vec::new();
918    let mut kept_caddyfile = false;
919    let mut kept_quadlet = false;
920    let caddyfile_path = caddy::caddyfile_path().ok();
921
922    let home_dir = service_home(service_name)?;
923    for step in &result.steps {
924        match step {
925            // Install-only — configure issues a Restart at the very end if needed.
926            Step::StartService { .. } => continue,
927            // Home dir already exists.
928            Step::CreateDir(p) if p == &home_dir => continue,
929            // Image pulls are idempotent and rare to need on configure.
930            Step::PullImage { .. } => continue,
931            // Defer until we know whether any write happened.
932            Step::DaemonReload | Step::ReloadCaddy | Step::Symlink { .. } => continue,
933            // Install-only Tailscale steps — configure decides via the
934            // explicit lifecycle flags below.
935            Step::TailscaleSetup | Step::TailscaleEnable { .. } | Step::TailscaleDisable { .. } => {
936                continue;
937            }
938            Step::WriteFile(file) => {
939                if unchanged.contains(&file.path) {
940                    continue;
941                }
942                if Some(&file.path) == caddyfile_path.as_ref() {
943                    kept_caddyfile = true;
944                }
945                // Quadlet files (`.container`, `.network`, `.volume`, …)
946                // live in `service_home/<name>.<ext>` and are *symlinked*
947                // into the quadlet dir. Detect by extension: the symlink
948                // in quadlet_dir is what `systemctl --user daemon-reload`
949                // picks up, but the target it points at is the write
950                // path we see here.
951                if is_quadlet_filename(&file.path) {
952                    kept_quadlet = true;
953                }
954                writes.push(Step::WriteFile(GeneratedFile {
955                    path: file.path.clone(),
956                    content: file.content.clone(),
957                }));
958            }
959            Step::CopyFile { src, dst } => {
960                copies.push(Step::CopyFile {
961                    src: src.clone(),
962                    dst: dst.clone(),
963                });
964            }
965            other => copies.push(clone_step(other)),
966        }
967    }
968
969    // Removed files: planner didn't emit them; rebuild the delete steps.
970    let mut removals: Vec<Step> = Vec::new();
971    for entry in &diff.entries {
972        if matches!(entry.kind, DiffKind::Removed) && entry.path.exists() {
973            removals.push(Step::RemoveFile(entry.path.clone()));
974        }
975    }
976
977    // Caddy route teardown: emit when configure removes the URL *or*
978    // when changing to a non-Caddy exposure (Loopback / Tailscale). The
979    // add path strips and re-adds the block atomically when the URL
980    // changes from one Caddy-routed value to another, so we only need a
981    // teardown here for the *leaving Caddy* case.
982    let prior_exp = current_url
983        .map(Exposure::from_url)
984        .unwrap_or(Exposure::Loopback);
985    let target_exp = target_url
986        .map(Exposure::from_url)
987        .unwrap_or(Exposure::Loopback);
988    let prior_caddy = matches!(
989        prior_exp,
990        Exposure::Internal { .. } | Exposure::Public { .. }
991    );
992    let target_caddy = matches!(
993        target_exp,
994        Exposure::Internal { .. } | Exposure::Public { .. }
995    );
996    let mut url_teardown: Vec<Step> = Vec::new();
997    if prior_caddy
998        && !target_caddy
999        && let Some(prev) = current_url
1000        && let Some(s) = caddy_remove_route_steps(service_name, prev)?
1001    {
1002        url_teardown = s;
1003        kept_caddyfile = true;
1004    }
1005
1006    // OIDC unregister + Tailscale disable steps run on the *old* state.
1007    let mut unregister_steps: Vec<Step> = Vec::new();
1008    if needs_unregister {
1009        unregister_steps = authelia::unregister_oidc_client(service_name)?;
1010    }
1011    let mut tailscale_disable_steps: Vec<Step> = Vec::new();
1012    if needs_tailscale_disable
1013        && let Some(svc_name) = current_url
1014            .map(Exposure::from_url)
1015            .as_ref()
1016            .and_then(|e| e.tailscale_svc_name())
1017    {
1018        tailscale_disable_steps.push(Step::TailscaleDisable { svc_name });
1019    }
1020
1021    // OIDC register + Tailscale enable steps run on the *new* state.
1022    let mut register_steps: Vec<Step> = Vec::new();
1023    if needs_register {
1024        let (client_id, client_secret) = match minted_oidc {
1025            Some((id, secret)) => (id.clone(), secret.clone()),
1026            None => {
1027                // URL change on a service that was already auth-enabled.
1028                // Reuse the existing credentials so authelia's new entry
1029                // matches whatever the service's `.env` already holds.
1030                let env = read_existing_env_keys(service_name)?;
1031                let id = service_def
1032                    .mappings
1033                    .auth
1034                    .iter()
1035                    .find(|(_, v)| v.trim() == "{{auth.client_id}}")
1036                    .and_then(|(k, _)| env.get(k).map(|v| trim_env_value(v)))
1037                    .ok_or_else(|| {
1038                        Error::AuthContext(format!(
1039                            "service '{service_name}' has auth=oidc in metadata but no \
1040                             OAUTH_CLIENT_ID-shaped env var found — cannot re-register OIDC \
1041                             client at the new URL"
1042                        ))
1043                    })?;
1044                let secret = service_def
1045                    .mappings
1046                    .auth
1047                    .iter()
1048                    .find(|(_, v)| v.trim() == "{{auth.client_secret}}")
1049                    .and_then(|(k, _)| env.get(k).map(|v| trim_env_value(v)))
1050                    .unwrap_or_default();
1051                (id, secret)
1052            }
1053        };
1054        let mut ctx: BTreeMap<String, String> = BTreeMap::new();
1055        ctx.insert("auth.client_id".into(), client_id);
1056        ctx.insert("auth.client_secret".into(), client_secret);
1057        if let Some(u) = target_url {
1058            ctx.insert("service.url".into(), u.to_string());
1059        }
1060        let qdir = quadlet_dir()?;
1061        register_steps =
1062            authelia::register_oidc_client(service_name, service_def, target_url, &ctx, &qdir)?;
1063    }
1064    let mut tailscale_enable_steps: Vec<Step> = Vec::new();
1065    if needs_tailscale_enable
1066        && let Some(svc_name) = target_url
1067            .map(Exposure::from_url)
1068            .as_ref()
1069            .and_then(|e| e.tailscale_svc_name())
1070    {
1071        let primary = result
1072            .allocated_ports
1073            .iter()
1074            .find(|(n, _)| n.eq_ignore_ascii_case("http"))
1075            .or_else(|| result.allocated_ports.first())
1076            .map(|(_, p)| *p);
1077        let ts_ports =
1078            crate::plan::tailscale_ports(&service_def.ports, &result.allocated_ports, primary);
1079        if !ts_ports.is_empty() {
1080            tailscale_enable_steps.push(Step::TailscaleSetup);
1081            tailscale_enable_steps.push(Step::TailscaleEnable {
1082                svc_name,
1083                ports: ts_ports,
1084            });
1085        }
1086    }
1087
1088    let any_file_change = !writes.is_empty() || !removals.is_empty() || !url_teardown.is_empty();
1089    let any_lifecycle = !unregister_steps.is_empty()
1090        || !register_steps.is_empty()
1091        || !tailscale_disable_steps.is_empty()
1092        || !tailscale_enable_steps.is_empty();
1093    if !any_file_change && !any_lifecycle {
1094        return Ok(Vec::new());
1095    }
1096    // Restart only when something the *container* actually sees has
1097    // changed: a quadlet rewrite, a `.env` rewrite, a script/cert
1098    // appearing or disappearing under service_home, a Caddyfile rewrite
1099    // that fronts this service, or an OIDC / Tailscale lifecycle step.
1100    // Metadata-only changes (backup_enabled, smtp_enabled flag) live in
1101    // `metadata.toml` and don't touch the running unit — restarting on
1102    // those eats systemd's `StartLimitBurst` budget for no reason.
1103    // ryra's own bookkeeping files (metadata.toml + service.manifest)
1104    // never reach the container — they're pure state for `ryra list`,
1105    // `ryra upgrade`, etc. A write that only touches these doesn't
1106    // warrant a restart.
1107    let manifest_file = manifest::manifest_path(service_name).ok();
1108    let metadata_file = manifest_file
1109        .as_ref()
1110        .and_then(|p| p.parent().map(|p| p.join("metadata.toml")));
1111    let writes_affect_runtime = writes.iter().any(|s| match s {
1112        Step::WriteFile(f) => {
1113            Some(&f.path) != metadata_file.as_ref() && Some(&f.path) != manifest_file.as_ref()
1114        }
1115        _ => false,
1116    });
1117    let needs_restart =
1118        writes_affect_runtime || !removals.is_empty() || !url_teardown.is_empty() || any_lifecycle;
1119
1120    let mut steps: Vec<Step> = Vec::new();
1121    // Forward Symlinks alongside their WriteFile pairs.
1122    for step in &result.steps {
1123        if let Step::Symlink { link, target } = step
1124            && writes
1125                .iter()
1126                .any(|s| matches!(s, Step::WriteFile(f) if &f.path == target))
1127        {
1128            steps.push(Step::Symlink {
1129                link: link.clone(),
1130                target: target.clone(),
1131            });
1132        }
1133    }
1134    steps.splice(0..0, writes);
1135    steps.extend(copies);
1136    steps.extend(removals);
1137    steps.extend(url_teardown);
1138    steps.extend(unregister_steps);
1139    steps.extend(tailscale_disable_steps);
1140    if kept_quadlet {
1141        steps.push(Step::DaemonReload);
1142    }
1143    if kept_caddyfile {
1144        steps.push(Step::ReloadCaddy);
1145    }
1146    steps.extend(tailscale_enable_steps);
1147    steps.extend(register_steps);
1148    if needs_restart {
1149        steps.push(Step::RestartService {
1150            unit: service_name.to_string(),
1151        });
1152    }
1153    Ok(steps)
1154}
1155
1156/// When configure is dropping a URL, emit the Caddyfile mutation that
1157/// strips the matching `# Service-Source: registry/<svc>` block, plus a
1158/// `ReloadCaddy` step. Returns `None` if Caddy isn't installed or no
1159/// block matches — both legitimate states for a non-Caddy-routed URL.
1160fn caddy_remove_route_steps(service_name: &str, prior_url: &str) -> Result<Option<Vec<Step>>> {
1161    use crate::{Capability, find_installed_provider};
1162    let installed = list_installed().unwrap_or_default();
1163    if find_installed_provider(&installed, Capability::ReverseProxy).is_none() {
1164        return Ok(None);
1165    }
1166    // Loopback / Tailscale never had a Caddy route — skip the rewrite.
1167    let prior_exp = Exposure::from_url(prior_url);
1168    if matches!(prior_exp, Exposure::Loopback | Exposure::Tailscale { .. }) {
1169        return Ok(None);
1170    }
1171    if WellKnownService::Caddy.matches(service_name) {
1172        return Ok(None);
1173    }
1174    let caddyfile_path = caddy::caddyfile_path()?;
1175    if !caddyfile_path.exists() {
1176        return Ok(None);
1177    }
1178    let existing = std::fs::read_to_string(&caddyfile_path).map_err(|source| Error::FileRead {
1179        path: caddyfile_path.clone(),
1180        source,
1181    })?;
1182    let updated = caddy::remove_route(&existing, service_name);
1183    if updated == existing {
1184        return Ok(None);
1185    }
1186    let mut out: Vec<Step> = Vec::new();
1187    out.push(Step::WriteFile(GeneratedFile {
1188        path: caddyfile_path,
1189        content: updated.clone(),
1190    }));
1191    if !updated.trim().is_empty() {
1192        out.push(Step::ReloadCaddy);
1193    }
1194    Ok(Some(out))
1195}
1196
1197/// Read `.env` and reconstruct the template context entries the planner
1198/// would otherwise have to regenerate. Every `KEY=VALUE` line whose `KEY`
1199/// matches one of the service's `{{secret.<name>}}` or `{{auth.<name>}}`
1200/// references seeds the context with the on-disk value, so `add_service`
1201/// (called in upgrade mode) reuses the existing credentials verbatim
1202/// instead of minting fresh ones.
1203fn recover_template_ctx(
1204    service_name: &str,
1205    def: &registry::service_def::ServiceDef,
1206) -> Result<BTreeMap<String, String>> {
1207    let existing_env = read_existing_env_keys(service_name)?;
1208    if existing_env.is_empty() {
1209        return Ok(BTreeMap::new());
1210    }
1211    let mut ctx = BTreeMap::new();
1212
1213    let collect_secrets = |value: &str, out: &mut Vec<String>| {
1214        let mut rest = value;
1215        while let Some(start) = rest.find("{{secret.") {
1216            let after = &rest[start + 9..];
1217            if let Some(end) = after.find("}}") {
1218                out.push(after[..end].to_string());
1219                rest = &after[end + 2..];
1220            } else {
1221                break;
1222            }
1223        }
1224    };
1225    let collect_auth = |value: &str, out: &mut Vec<String>| {
1226        for needle in ["{{auth.client_id", "{{auth.client_secret"] {
1227            if value.contains(needle) {
1228                let stripped = needle.trim_start_matches("{{auth.");
1229                out.push(stripped.to_string());
1230            }
1231        }
1232    };
1233
1234    let mut secret_pairs: Vec<(String, String)> = Vec::new();
1235    let mut auth_keys: Vec<String> = Vec::new();
1236
1237    let mut consider = |env: &registry::service_def::EnvVar| {
1238        let trimmed = env.value.trim();
1239        if let Some(name) = trimmed
1240            .strip_prefix("{{secret.")
1241            .and_then(|s| s.strip_suffix("}}"))
1242            && let Some(live) = existing_env.get(&env.name)
1243        {
1244            secret_pairs.push((name.to_string(), trim_env_value(live)));
1245        }
1246        let mut extras: Vec<String> = Vec::new();
1247        collect_secrets(&env.value, &mut extras);
1248        for n in extras {
1249            if !secret_pairs.iter().any(|(k, _)| k == &n) {
1250                secret_pairs.push((n, String::new()));
1251            }
1252        }
1253        let mut auth_refs: Vec<String> = Vec::new();
1254        collect_auth(&env.value, &mut auth_refs);
1255        for n in auth_refs {
1256            if !auth_keys.contains(&n) {
1257                auth_keys.push(n);
1258            }
1259        }
1260    };
1261
1262    for e in &def.env {
1263        consider(e);
1264    }
1265    for g in &def.env_groups {
1266        for e in &g.env {
1267            consider(e);
1268        }
1269    }
1270    for (env_name, value_template) in &def.mappings.auth {
1271        let env = registry::service_def::EnvVar {
1272            name: env_name.clone(),
1273            value: value_template.clone(),
1274            kind: Default::default(),
1275            prompt: None,
1276            format: Default::default(),
1277            length: None,
1278            jwt_claims: None,
1279            jwt_signing_key: None,
1280        };
1281        consider(&env);
1282    }
1283
1284    for (name, value) in &secret_pairs {
1285        if !value.is_empty() {
1286            ctx.insert(format!("secret.{name}"), value.clone());
1287        }
1288    }
1289    for (env_name, value_template) in &def.mappings.auth {
1290        let trimmed = value_template.trim();
1291        if let Some(rest) = trimmed
1292            .strip_prefix("{{auth.")
1293            .and_then(|s| s.strip_suffix("}}"))
1294            && let Some(live) = existing_env.get(env_name)
1295        {
1296            ctx.insert(format!("auth.{rest}"), trim_env_value(live));
1297        }
1298    }
1299
1300    Ok(ctx)
1301}
1302
1303fn trim_env_value(raw: &str) -> String {
1304    raw.trim_matches(|c: char| c == '"' || c == '\'')
1305        .to_string()
1306}
1307
1308/// True when `path`'s filename ends in a podman-quadlet extension. Quadlet
1309/// regenerates a `.service` per matching file on every
1310/// `systemctl --user daemon-reload`, so a write to any of these means we
1311/// need to emit a reload before restarting.
1312fn is_quadlet_filename(path: &std::path::Path) -> bool {
1313    matches!(
1314        path.extension().and_then(|e| e.to_str()),
1315        Some("container" | "volume" | "network" | "kube" | "image" | "pod" | "build")
1316    )
1317}
1318
1319/// Parse the on-disk `.env` for a service into a key→value map.
1320fn read_existing_env_keys(service_name: &str) -> Result<BTreeMap<String, String>> {
1321    let env_path = service_home(service_name)?.join(".env");
1322    let mut out: BTreeMap<String, String> = BTreeMap::new();
1323    let content = match std::fs::read_to_string(&env_path) {
1324        Ok(c) => c,
1325        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
1326        Err(source) => {
1327            return Err(Error::FileRead {
1328                path: env_path,
1329                source,
1330            });
1331        }
1332    };
1333    for line in content.lines() {
1334        let line = line.trim();
1335        if line.is_empty() || line.starts_with('#') {
1336            continue;
1337        }
1338        if let Some((k, v)) = line.split_once('=') {
1339            out.insert(k.trim().to_string(), v.to_string());
1340        }
1341    }
1342    Ok(out)
1343}
1344
1345/// Pin existing host ports across re-renders.
1346fn read_existing_ports(service_name: &str) -> Result<BTreeMap<String, u16>> {
1347    let env_path = service_home(service_name)?.join(".env");
1348    let mut overrides = BTreeMap::new();
1349    let content = match std::fs::read_to_string(&env_path) {
1350        Ok(c) => c,
1351        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(overrides),
1352        Err(source) => {
1353            return Err(Error::FileRead {
1354                path: env_path,
1355                source,
1356            });
1357        }
1358    };
1359    for line in content.lines() {
1360        let line = line.trim();
1361        if line.is_empty() || line.starts_with('#') {
1362            continue;
1363        }
1364        let Some((key, value)) = line.split_once('=') else {
1365            continue;
1366        };
1367        let Some(name) = key.strip_prefix("SERVICE_PORT_") else {
1368            continue;
1369        };
1370        if let Ok(port) = value.trim().parse::<u16>() {
1371            overrides.insert(name.to_ascii_lowercase(), port);
1372        }
1373    }
1374    Ok(overrides)
1375}
1376
1377/// Clone a `Step` explicitly. `Step` carries non-`Clone` payloads in
1378/// places; we list each variant so a new one forces a compile error
1379/// here rather than silently being dropped.
1380fn clone_step(step: &Step) -> Step {
1381    match step {
1382        Step::WriteFile(f) => Step::WriteFile(GeneratedFile {
1383            path: f.path.clone(),
1384            content: f.content.clone(),
1385        }),
1386        Step::Symlink { link, target } => Step::Symlink {
1387            link: link.clone(),
1388            target: target.clone(),
1389        },
1390        Step::DaemonReload => Step::DaemonReload,
1391        Step::StartService { unit } => Step::StartService { unit: unit.clone() },
1392        Step::EnableService { unit } => Step::EnableService { unit: unit.clone() },
1393        Step::DisableService { unit } => Step::DisableService { unit: unit.clone() },
1394        Step::StopService { unit } => Step::StopService { unit: unit.clone() },
1395        Step::RestartService { unit } => Step::RestartService { unit: unit.clone() },
1396        Step::ReloadCaddy => Step::ReloadCaddy,
1397        Step::PullImage { image } => Step::PullImage {
1398            image: image.clone(),
1399        },
1400        Step::RemoveFile(p) => Step::RemoveFile(p.clone()),
1401        Step::RemoveDir(p) => Step::RemoveDir(p.clone()),
1402        Step::RemoveVolume { name } => Step::RemoveVolume { name: name.clone() },
1403        Step::RemoveNetwork { name } => Step::RemoveNetwork { name: name.clone() },
1404        Step::CreateDir(p) => Step::CreateDir(p.clone()),
1405        Step::WaitForFile { path, timeout_secs } => Step::WaitForFile {
1406            path: path.clone(),
1407            timeout_secs: *timeout_secs,
1408        },
1409        Step::WaitForHttpHealthy {
1410            url,
1411            expect_status,
1412            timeout_secs,
1413        } => Step::WaitForHttpHealthy {
1414            url: url.clone(),
1415            expect_status: *expect_status,
1416            timeout_secs: *timeout_secs,
1417        },
1418        Step::CopyFile { src, dst } => Step::CopyFile {
1419            src: src.clone(),
1420            dst: dst.clone(),
1421        },
1422        Step::Build { dir, command } => Step::Build {
1423            dir: dir.clone(),
1424            command: command.clone(),
1425        },
1426        Step::SyncDir { src, dst } => Step::SyncDir {
1427            src: src.clone(),
1428            dst: dst.clone(),
1429        },
1430        Step::TailscaleSetup => Step::TailscaleSetup,
1431        Step::TailscaleEnable { svc_name, ports } => Step::TailscaleEnable {
1432            svc_name: svc_name.clone(),
1433            ports: ports.clone(),
1434        },
1435        Step::TailscaleDisable { svc_name } => Step::TailscaleDisable {
1436            svc_name: svc_name.clone(),
1437        },
1438    }
1439}
1440
1441#[cfg(test)]
1442mod tests {
1443    use super::*;
1444
1445    /// The line-level merge is the safety contract for reconcile: it must
1446    /// rewrite only the changed keys and leave everything else — comments,
1447    /// secrets, ports, user-added keys, file order — byte-for-byte intact.
1448    #[test]
1449    fn merge_rewrites_only_changed_keys() {
1450        let existing = "\
1451# generated by ryra
1452SMTP_HOST=old.example.com
1453SMTP_PORT=587
1454POSTGRES_PASSWORD=s3cret-unchanged
1455ADMIN_EMAIL=me@example.com
1456SERVICE_PORT_HTTP=8080
1457USER_ADDED=keep-me
1458";
1459        let changes = vec![
1460            EnvKeyChange {
1461                key: "SMTP_HOST".into(),
1462                from: Some("old.example.com".into()),
1463                to: "new.example.com".into(),
1464                secret: false,
1465            },
1466            // A key not yet present — appended, never inserted mid-file.
1467            EnvKeyChange {
1468                key: "SMTP_FROM".into(),
1469                from: None,
1470                to: "noreply@new.example.com".into(),
1471                secret: false,
1472            },
1473        ];
1474        let merged = merge_env_changes(existing, &changes);
1475        let parsed = parse_env_content(&merged);
1476        assert_eq!(
1477            parsed.get("SMTP_HOST").map(String::as_str),
1478            Some("new.example.com")
1479        );
1480        assert_eq!(
1481            parsed.get("SMTP_FROM").map(String::as_str),
1482            Some("noreply@new.example.com")
1483        );
1484        // Untouched lines survive verbatim.
1485        assert_eq!(
1486            parsed.get("POSTGRES_PASSWORD").map(String::as_str),
1487            Some("s3cret-unchanged")
1488        );
1489        assert_eq!(
1490            parsed.get("USER_ADDED").map(String::as_str),
1491            Some("keep-me")
1492        );
1493        assert_eq!(
1494            parsed.get("SERVICE_PORT_HTTP").map(String::as_str),
1495            Some("8080")
1496        );
1497        // The comment header is preserved.
1498        assert!(merged.starts_with("# generated by ryra\n"));
1499        // No duplicate SMTP_HOST line was appended.
1500        assert_eq!(merged.matches("SMTP_HOST=").count(), 1);
1501    }
1502
1503    /// The is_destructive matrix is the safety contract: it decides
1504    /// whether the CLI demands typed confirmation. One table-driven test
1505    /// makes it cheap to spot a regression in any cell.
1506    #[test]
1507    fn destructive_classification() {
1508        let url = |from: Option<&str>, to: Option<&str>| ConfigureChange::Url {
1509            from: from.map(str::to_string),
1510            to: to.map(str::to_string),
1511        };
1512        let cases: &[(ConfigureChange, bool)] = &[
1513            // URL: changing or removing destroys old routes / OAuth callbacks.
1514            (url(Some("https://old"), Some("https://new")), true),
1515            (url(Some("https://old"), None), true),
1516            (url(None, Some("https://new")), false),
1517            (url(Some("https://x"), Some("https://x")), false),
1518            // Toggles: only the off direction is destructive.
1519            (
1520                ConfigureChange::Smtp {
1521                    from: true,
1522                    to: false,
1523                },
1524                true,
1525            ),
1526            (
1527                ConfigureChange::Smtp {
1528                    from: false,
1529                    to: true,
1530                },
1531                false,
1532            ),
1533            (
1534                ConfigureChange::Backup {
1535                    from: true,
1536                    to: false,
1537                },
1538                true,
1539            ),
1540            (
1541                ConfigureChange::Backup {
1542                    from: false,
1543                    to: true,
1544                },
1545                false,
1546            ),
1547            (
1548                ConfigureChange::Auth {
1549                    from: true,
1550                    to: false,
1551                },
1552                true,
1553            ),
1554            (
1555                ConfigureChange::Auth {
1556                    from: false,
1557                    to: true,
1558                },
1559                false,
1560            ),
1561            // Group disable drops env vars; enable just adds them.
1562            (ConfigureChange::GroupDisabled("oauth".into()), true),
1563            (ConfigureChange::GroupEnabled("oauth".into()), false),
1564            // Explicit user override: never a surprise.
1565            (
1566                ConfigureChange::EnvOverride {
1567                    key: "ADMIN_EMAIL".into(),
1568                    from: Some("a".into()),
1569                    to: "b".into(),
1570                },
1571                false,
1572            ),
1573        ];
1574        for (change, expected) in cases {
1575            assert_eq!(
1576                change.is_destructive(),
1577                *expected,
1578                "wrong classification for {change:?}"
1579            );
1580        }
1581    }
1582}