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 })?;
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 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
400fn 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
411fn 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
427fn 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
459pub 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 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 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 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 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 if !current_auth
592 && target_auth
593 && reg_service.def.integrations.auth.is_empty()
594 && !crate::capability::def_provides(®_service.def, crate::Capability::OidcProvider)
595 {
596 return Err(Error::NoOidcSupport(service_name.to_string()));
597 }
598 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 let mut pre_built_ctx = recover_template_ctx(service_name, ®_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 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_mode: None,
672 mode: PlanMode::Upgrade,
673 port_overrides: &port_overrides,
674 })?;
675
676 let diff = build_diff(service_name, &result)?;
677
678 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(¤t_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 let url_changed = current_url != target_url;
733 let needs_unregister = current_auth && (!target_auth || url_changed);
734 let needs_register = target_auth && (!current_auth || url_changed || overrides.reassert_auth);
739 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 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 ®_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
787fn 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 (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 source_stale: false,
874 })
875}
876
877#[allow(clippy::too_many_arguments)]
896fn build_configure_steps(
897 service_name: &str,
898 result: &AddResult,
899 service_def: ®istry::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 Step::StartService { .. } => continue,
927 Step::CreateDir(p) if p == &home_dir => continue,
929 Step::PullImage { .. } => continue,
931 Step::DaemonReload | Step::ReloadCaddy | Step::Symlink { .. } => continue,
933 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 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 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 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 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 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 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 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 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
1156fn 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 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
1197fn recover_template_ctx(
1204 service_name: &str,
1205 def: ®istry::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: ®istry::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
1308fn 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
1319fn 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
1345fn 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
1377fn 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 #[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 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 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 assert!(merged.starts_with("# generated by ryra\n"));
1499 assert_eq!(merged.matches("SMTP_HOST=").count(), 1);
1501 }
1502
1503 #[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(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 (
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 (ConfigureChange::GroupDisabled("oauth".into()), true),
1563 (ConfigureChange::GroupEnabled("oauth".into()), false),
1564 (
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}