1use 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#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
56#[serde(default)]
57pub struct Overrides {
58 pub exposure: Option<ExposureChange>,
61 pub smtp: Option<bool>,
64 pub backup: Option<bool>,
66 pub auth: Option<bool>,
70 pub enable_groups: BTreeSet<String>,
72 pub disable_groups: BTreeSet<String>,
74 pub choose: BTreeMap<String, String>,
77 pub env_overrides: BTreeMap<String, String>,
81 pub reassert_auth: bool,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
92#[serde(rename_all = "snake_case")]
93pub enum ExposureChange {
94 Url(String),
97 Tailscale(String),
100 Loopback,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
108pub enum ConfigureChange {
109 Url {
112 from: Option<String>,
113 to: Option<String>,
114 },
115 Smtp { from: bool, to: bool },
117 Backup { from: bool, to: bool },
119 Auth { from: bool, to: bool },
121 GroupEnabled(String),
123 GroupDisabled(String),
125 EnvOverride {
127 key: String,
128 from: Option<String>,
129 to: String,
130 },
131}
132
133impl ConfigureChange {
134 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
171pub struct ConfigureResult {
173 pub service: String,
174 pub changes: Vec<ConfigureChange>,
177 pub diff: DiffResult,
181 pub steps: Vec<Step>,
184 pub has_destructive: bool,
186}
187
188impl ConfigureResult {
189 pub fn is_noop(&self) -> bool {
195 self.steps.is_empty()
196 }
197}
198
199#[derive(Debug, Clone, PartialEq, Eq)]
202pub struct EnvKeyChange {
203 pub key: String,
204 pub from: Option<String>,
207 pub to: String,
208 pub secret: bool,
211}
212
213pub struct ServiceReconcile {
217 pub service: String,
218 pub changes: Vec<EnvKeyChange>,
221 pub steps: Vec<Step>,
224}
225
226pub 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 = ®_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 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: ®istry::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 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 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
404fn 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
415fn 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
431fn 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
463pub 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 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 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 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 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 if !current_auth
596 && target_auth
597 && reg_service.def.integrations.auth.is_empty()
598 && !crate::capability::def_provides(®_service.def, crate::Capability::OidcProvider)
599 {
600 return Err(Error::NoOidcSupport(service_name.to_string()));
601 }
602 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 let mut pre_built_ctx = recover_template_ctx(service_name, ®_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 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_mode: None,
676 mode: PlanMode::Upgrade,
677 port_overrides: &port_overrides,
678 existing_env_file: None,
681 allow_unset_required: false,
682 })?;
683
684 let diff = build_diff(service_name, &result)?;
685
686 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(¤t_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 let url_changed = current_url != target_url;
741 let needs_unregister = current_auth && (!target_auth || url_changed);
742 let needs_register = target_auth && (!current_auth || url_changed || overrides.reassert_auth);
747 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 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 ®_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
795fn 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 (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 source_stale: false,
882 })
883}
884
885#[allow(clippy::too_many_arguments)]
904fn build_configure_steps(
905 service_name: &str,
906 result: &AddResult,
907 service_def: ®istry::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 Step::StartService { .. } => continue,
935 Step::CreateDir(p) if p == &home_dir => continue,
937 Step::PullImage { .. } => continue,
939 Step::DaemonReload | Step::ReloadCaddy | Step::Symlink { .. } => continue,
941 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 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 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 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 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 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 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 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 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
1164fn 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 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
1205fn recover_template_ctx(
1212 service_name: &str,
1213 def: ®istry::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: ®istry::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
1316fn 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
1327fn 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
1353fn 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
1385fn 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 #[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 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 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 assert!(merged.starts_with("# generated by ryra\n"));
1507 assert_eq!(merged.matches("SMTP_HOST=").count(), 1);
1509 }
1510
1511 #[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(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 (
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 (ConfigureChange::GroupDisabled("oauth".into()), true),
1571 (ConfigureChange::GroupEnabled("oauth".into()), false),
1572 (
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}