1pub mod auth_bridge;
2pub mod authelia;
3pub mod backup;
4pub mod caddy;
5pub mod capability;
6pub mod config;
7pub mod configure;
8pub mod data;
9pub mod deploy;
10pub mod error;
11pub mod exposure;
12pub mod from_protocol;
13pub mod generate;
14pub mod manifest;
15pub mod metadata;
16pub mod metrics_bridge;
17pub mod ops;
18pub mod paths;
19pub mod plan;
20pub mod registry;
21pub mod system;
22pub mod to_protocol;
23pub mod upgrade;
24pub mod well_known;
25
26use std::collections::BTreeMap;
27use std::path::{Path, PathBuf};
28
29use config::ConfigPaths;
30use config::schema::InstalledService;
31use error::{Error, Result};
32
33pub use capability::{
34 Capability, any_installed_provider, find_installed_provider, installed_provides,
35 service_provides,
36};
37pub use configure::{
38 ConfigureChange, ConfigureResult, EnvKeyChange, ExposureChange,
39 Overrides as ConfigureOverrides, ServiceReconcile, configure_service, reconcile_service,
40};
41pub use exposure::{
42 Exposure, check_auth_exposure_compat, is_caddy_local_url, is_public_url, is_tailscale_url,
43};
44pub use generate::GeneratedFile;
45pub use manifest::{ManifestEntry, manifest_path};
46pub use metadata::{Metadata, load_metadata};
47pub use paths::{
48 CONFIG_DIR_ENV, DATA_DIR_ENV, DEFAULT_REGISTRY_URL, REGISTRY_DEFAULT, REGISTRY_DIR_ENV,
49 metadata_path, quadlet_dir, service_data_root, service_home, systemd_user_dir,
50};
51pub use plan::{AddResult, RemoveResult, ResetResult, Step, TailscalePort, TrackedEnv, Warning};
52pub use upgrade::{
53 BackupSnapshot, DEFAULT_BACKUP_KEEP, DiffEntry, DiffKind, DiffResult, EnvAddition,
54 RevertResult, UpgradeResult, diff_service, list_backups, prune_backups, revert_service,
55 upgrade_service,
56};
57pub use well_known::WellKnownService;
58
59pub(crate) use paths::home_dir;
60pub(crate) use well_known::caddy_https_port;
61
62pub async fn resolve_registry_dir(service_ref: ®istry::resolve::ServiceRef) -> Result<PathBuf> {
64 let paths = ConfigPaths::resolve()?;
65 paths.ensure_cache_dir()?;
66 let config = config::load_or_default(&paths.config_file)?;
67 registry::resolve::resolve_registry_dir(service_ref, &config, &paths.cache_dir).await
68}
69
70pub fn service_ref_from_installed(installed: &InstalledService) -> registry::resolve::ServiceRef {
72 if installed.repo.is_empty() || installed.repo == REGISTRY_DEFAULT {
73 registry::resolve::ServiceRef::Default(installed.name.clone())
74 } else {
75 registry::resolve::ServiceRef::Custom {
76 registry: installed.repo.clone(),
77 service: installed.name.clone(),
78 }
79 }
80}
81
82fn retroactive_network_joins(
91 new_service: &str,
92 quadlet_path: &std::path::Path,
93 _repo_dir: Option<&std::path::Path>,
94) -> Vec<Step> {
95 let mut steps = Vec::new();
96 let new_cap = if service_provides(new_service, Capability::ReverseProxy) {
101 Capability::ReverseProxy
102 } else if service_provides(new_service, Capability::SmtpRelay) {
103 Capability::SmtpRelay
104 } else {
105 return steps;
106 };
107
108 let installed = list_installed().unwrap_or_default();
109 for svc in &installed {
110 if !svc.provides.is_empty() {
113 continue;
114 }
115 let (network_name, should_join) = match new_cap {
116 Capability::ReverseProxy => {
117 let wants_proxy = matches!(
121 svc.exposure,
122 Exposure::Internal { .. } | Exposure::Public { .. }
123 );
124 (new_service.to_string(), wants_proxy)
125 }
126 Capability::SmtpRelay => {
127 (
130 new_service.to_string(),
131 service_uses_smtp_relay(&svc.name, new_service),
132 )
133 }
134 Capability::OidcProvider
140 | Capability::ForwardAuthProvider
141 | Capability::MetricsStore
142 | Capability::MetricsDashboard => {
143 continue;
144 }
145 };
146 if !should_join {
147 continue;
148 }
149 let installed_names_owned: Vec<String> = installed.iter().map(|s| s.name.clone()).collect();
150 let all_service_names: Vec<&str> =
151 installed_names_owned.iter().map(|s| s.as_str()).collect();
152 steps.extend(network_join_steps(
153 &svc.name,
154 &network_name,
155 quadlet_path,
156 &all_service_names,
157 ));
158 }
159 steps
160}
161
162fn network_join_steps(
172 svc_name: &str,
173 network_name: &str,
174 quadlet_path: &std::path::Path,
175 all_service_names: &[&str],
176) -> Vec<Step> {
177 let mut steps = Vec::new();
178 let marker = format!("Network={network_name}.network");
179 let mut units_to_restart: Vec<String> = Vec::new();
180 let Ok(entries) = std::fs::read_dir(quadlet_path) else {
181 return steps;
182 };
183 for entry in entries.flatten() {
184 let path = entry.path();
185 let name = match path.file_name().and_then(|n| n.to_str()) {
186 Some(n) if n.ends_with(".container") => n.to_string(),
187 _ => continue,
188 };
189 if !quadlet_belongs_to(&name, svc_name, all_service_names) {
190 continue;
191 }
192 let content = match std::fs::read_to_string(&path) {
193 Ok(c) => c,
194 Err(_) => continue,
195 };
196 if content.contains(&marker) {
197 continue;
198 }
199 let real_path = match std::fs::canonicalize(&path) {
203 Ok(p) => p,
204 Err(_) => continue,
205 };
206 let updated = generate::bundle::inject_networks(
207 &content,
208 std::slice::from_ref(&network_name.to_string()),
209 );
210 steps.push(Step::WriteFile(GeneratedFile {
211 path: real_path,
212 content: updated,
213 }));
214 let unit = name.trim_end_matches(".container").to_string();
217 units_to_restart.push(unit);
218 }
219 if !units_to_restart.is_empty() {
220 steps.push(Step::DaemonReload);
221 for unit in units_to_restart {
222 steps.push(Step::RestartService { unit });
223 }
224 }
225 steps
226}
227
228fn store_container_port(store_name: &str) -> Option<u16> {
231 let def = capability::lookup_registry_def(store_name)?;
232 def.ports
233 .iter()
234 .find(|p| p.name.eq_ignore_ascii_case("http"))
235 .or_else(|| def.ports.first())
236 .map(|p| p.container_port)
237}
238
239fn retroactive_metrics_wiring(
245 store_name: &str,
246 store_def: ®istry::service_def::ServiceDef,
247 quadlet_path: &std::path::Path,
248) -> Vec<Step> {
249 let mut steps = Vec::new();
250 let installed = list_installed().unwrap_or_default();
251 let store_port = store_def
252 .ports
253 .iter()
254 .find(|p| p.name.eq_ignore_ascii_case("http"))
255 .or_else(|| store_def.ports.first())
256 .map(|p| p.container_port);
257 let installed_names_owned: Vec<String> = installed.iter().map(|s| s.name.clone()).collect();
258 let all_service_names: Vec<&str> = installed_names_owned.iter().map(|s| s.as_str()).collect();
259
260 for svc in &installed {
261 if svc.name == store_name {
262 continue;
263 }
264 let Some(def) = capability::lookup_registry_def(&svc.name) else {
268 continue;
269 };
270 let mut dashboard_wired = false;
271 let mut needs_join = false;
272 let metrics_host_port = def.metrics.as_ref().and_then(|m| {
277 upgrade::read_existing_ports(&svc.name)
278 .ok()
279 .and_then(|ports| ports.get(&m.port.to_ascii_lowercase()).copied())
280 });
281 if let Ok(Some(step)) =
282 metrics_bridge::scrape_target_step(store_name, &def, metrics_host_port)
283 {
284 steps.push(step);
285 needs_join = def.metrics.as_ref().is_some_and(|m| !m.host_network);
286 }
287 if def
288 .capabilities
289 .provides
290 .contains(&Capability::MetricsDashboard)
291 && let Some(port) = store_port
292 && let Ok(step) = metrics_bridge::datasource_step(&svc.name, store_name, port)
293 {
294 steps.push(step);
295 dashboard_wired = true;
296 needs_join = true;
297 }
298 if !needs_join {
299 continue;
300 }
301 let join_steps =
302 network_join_steps(&svc.name, store_name, quadlet_path, &all_service_names);
303 let restarts_main = join_steps
307 .iter()
308 .any(|s| matches!(s, Step::RestartService { unit } if unit == &svc.name));
309 steps.extend(join_steps);
310 if dashboard_wired && !restarts_main {
311 steps.push(Step::RestartService {
312 unit: svc.name.clone(),
313 });
314 }
315 }
316 steps
317}
318
319fn service_uses_smtp_relay(service_name: &str, relay_host: &str) -> bool {
325 let env_path = match service_home(service_name) {
326 Ok(h) => h.join(".env"),
327 Err(_) => return false,
328 };
329 let content = match std::fs::read_to_string(&env_path) {
330 Ok(c) => c,
331 Err(_) => return false,
332 };
333 let with_port = format!("{relay_host}:");
334 content.lines().any(|line| {
335 let Some((_, value)) = line.split_once('=') else {
336 return false;
337 };
338 let v = value.trim();
339 v == relay_host || v.starts_with(&with_port)
340 })
341}
342
343#[allow(clippy::too_many_arguments)]
358fn resolve_extra_networks(
359 service_name: &str,
360 enable_auth: bool,
361 authelia_installed: bool,
362 caddy_installed: bool,
363 inbucket_installed: bool,
364 has_url: bool,
365 has_smtp: bool,
366 metrics_store: Option<&str>,
367 wants_metrics: bool,
368) -> Vec<String> {
369 let mut networks = Vec::new();
370 if enable_auth && authelia_installed && !WellKnownService::Authelia.matches(service_name) {
371 networks.push(WellKnownService::Authelia.to_string());
372 }
373 let joins_inbucket =
376 has_smtp && inbucket_installed && !WellKnownService::Inbucket.matches(service_name);
377 if joins_inbucket {
378 networks.push(WellKnownService::Inbucket.to_string());
379 }
380 let joins_caddy = (has_url || enable_auth || WellKnownService::Inbucket.matches(service_name))
381 && caddy_installed
382 && !WellKnownService::Caddy.matches(service_name);
383 if joins_caddy && !networks.contains(&WellKnownService::Caddy.to_string()) {
384 networks.push(WellKnownService::Caddy.to_string());
385 }
386 if let Some(store) = metrics_store
389 && wants_metrics
390 && store != service_name
391 {
392 networks.push(store.to_string());
393 }
394 networks
395}
396
397#[derive(Debug, Clone, Copy, PartialEq, Eq)]
404pub enum PlanMode {
405 Add,
409 Upgrade,
413}
414
415#[derive(Debug, Clone, PartialEq, Eq)]
424pub enum AuthChoice {
425 None,
427 Native(registry::service_def::AuthKind),
430}
431
432impl AuthChoice {
433 pub fn enabled(&self) -> bool {
436 !matches!(self, AuthChoice::None)
437 }
438
439 pub fn native_kind(&self) -> Option<®istry::service_def::AuthKind> {
442 match self {
443 AuthChoice::Native(kind) => Some(kind),
444 AuthChoice::None => None,
445 }
446 }
447}
448
449pub struct AddServiceParams<'a> {
454 pub service_name: &'a str,
455 pub exposure: &'a Exposure,
456 pub auth: AuthChoice,
457 pub enable_smtp: bool,
458 pub enable_backup: bool,
459 pub env_overrides: &'a BTreeMap<String, String>,
460 pub enabled_groups: &'a std::collections::BTreeSet<String>,
461 pub selected_choices: &'a BTreeMap<String, String>,
464 pub registry_name: &'a str,
465 pub repo_dir: &'a Path,
466 pub pre_built_ctx: Option<BTreeMap<String, String>>,
470 pub port_in_use: &'a dyn Fn(u16) -> bool,
471 pub acme_mode: Option<&'a caddy::AcmeMode>,
472 pub mode: PlanMode,
473 pub port_overrides: &'a BTreeMap<String, u16>,
479 pub existing_env_file: Option<String>,
485 pub allow_unset_required: bool,
489}
490
491fn caddy_route_steps(
500 service_name: &str,
501 url: &str,
502 target_host: String,
503 upstream_port: u16,
504 host_port: Option<u16>,
505 caddy_installed: bool,
506 https_port: u16,
507) -> Result<(Vec<Step>, Vec<Warning>)> {
508 let mut steps = Vec::new();
509 let mut warnings = Vec::new();
510 if caddy_installed {
511 let parsed = url::Url::parse(url)
512 .map_err(|e| Error::Template(format!("invalid service URL '{url}': {e}")))?;
513 let domain = parsed.host_str().ok_or_else(|| {
514 Error::Template(format!(
515 "service URL '{url}' has no host — Caddy needs a hostname to route to"
516 ))
517 })?;
518 let block = caddy::render_site_block(&caddy::CaddySiteParams {
519 service_name: service_name.to_string(),
520 target_host,
521 domain: domain.to_string(),
522 container_port: upstream_port,
523 https_port,
524 force_internal_tls: false,
525 });
526 let caddyfile_path = caddy::caddyfile_path()?;
527 let existing =
528 std::fs::read_to_string(&caddyfile_path).map_err(|source| Error::FileRead {
529 path: caddyfile_path.clone(),
530 source,
531 })?;
532 let updated = caddy::add_route(&existing, service_name, &block);
533 steps.push(Step::WriteFile(GeneratedFile {
534 path: caddyfile_path,
535 content: updated,
536 }));
537 steps.push(Step::ReloadCaddy);
538 } else if let Some(primary) = host_port {
539 warnings.push(Warning::UrlWithoutReverseProxy {
542 service_name: service_name.to_string(),
543 url: url.to_string(),
544 host_port: primary,
545 });
546 }
547 Ok((steps, warnings))
548}
549
550pub fn add_service(params: AddServiceParams<'_>) -> Result<AddResult> {
552 let AddServiceParams {
553 service_name,
554 exposure,
555 auth,
556 enable_smtp,
557 enable_backup,
558 env_overrides,
559 enabled_groups,
560 selected_choices,
561 registry_name,
562 repo_dir,
563 pre_built_ctx,
564 port_in_use,
565 acme_mode,
566 mode,
567 port_overrides,
568 existing_env_file,
569 allow_unset_required,
570 } = params;
571 let auth_kind: Option<®istry::service_def::AuthKind> = auth.native_kind();
576 let enable_auth: bool = auth.enabled();
577 let url: Option<&str> = exposure.url();
578 let paths = ConfigPaths::resolve()?;
579 let config = config::load_or_default(&paths.config_file)?;
580
581 if mode == PlanMode::Add {
587 if is_service_installed(service_name) {
588 return Err(Error::ServiceAlreadyInstalled(service_name.to_string()));
589 }
590
591 if data::enumerate_service(service_name)?.is_some() {
599 return Err(Error::ServiceIncomplete(service_name.to_string()));
600 }
601 }
602
603 let reg_service = registry::find_service(repo_dir, service_name)?;
604
605 if let Some(msg) = reg_service.def.check_architecture() {
607 return Err(Error::UnsupportedArchitecture(msg));
608 }
609
610 let missing_requires: Vec<&str> = reg_service
615 .def
616 .requires
617 .iter()
618 .filter(|r| !is_service_installed(&r.service))
619 .map(|r| r.service.as_str())
620 .collect();
621 if !missing_requires.is_empty() {
622 return Err(Error::MissingRequiredServices {
623 service: service_name.to_string(),
624 missing: missing_requires.iter().map(|s| s.to_string()).collect(),
625 });
626 }
627
628 if auth_kind.is_some() && config.auth.is_none() {
630 return Err(Error::AuthNotConfigured);
631 }
632
633 if enable_auth
637 && reg_service.def.integrations.auth.is_empty()
638 && !capability::def_provides(®_service.def, Capability::OidcProvider)
639 {
640 return Err(Error::NoOidcSupport(service_name.to_string()));
641 }
642
643 if enable_backup && !reg_service.def.integrations.backup {
647 return Err(Error::BackupNotSupported(service_name.to_string()));
648 }
649
650 for g in enabled_groups {
654 if !reg_service.def.env_groups.iter().any(|eg| &eg.name == g) {
655 let known: Vec<String> = reg_service
656 .def
657 .env_groups
658 .iter()
659 .map(|eg| eg.name.clone())
660 .collect();
661 let hint = if known.is_empty() {
662 " (service defines no env_groups)".to_string()
663 } else {
664 format!(" (known: {})", known.join(", "))
665 };
666 return Err(Error::UnknownEnvGroup {
667 service: service_name.to_string(),
668 group: g.clone(),
669 hint,
670 });
671 }
672 }
673
674 let mut port_warnings: Vec<Warning> = Vec::new();
680 let mut effective_ports: Vec<®istry::service_def::PortDef> =
685 reg_service.def.ports.iter().collect();
686 for choice in ®_service.def.choices {
687 let sel = selected_choices
688 .get(&choice.name)
689 .unwrap_or(&choice.default);
690 if let Some(opt) = choice.options.iter().find(|o| &o.name == sel) {
691 effective_ports.extend(opt.ports.iter());
692 }
693 }
694 let mut claimed: std::collections::HashSet<u16> =
695 effective_ports.iter().filter_map(|p| p.host_port).collect();
696 let mut resolved_ports: Vec<(String, u16)> = Vec::with_capacity(effective_ports.len());
697 for p in effective_ports.iter().copied() {
698 let host = if let Some(pinned) = port_overrides.get(&p.name) {
699 *pinned
704 } else if let Some(hp) = p.host_port {
705 hp
706 } else {
707 let privileged = p.container_port < 1024;
708 let claimed_in_service = claimed.contains(&p.container_port);
709 let in_use = port_in_use(p.container_port);
710 if privileged || claimed_in_service || in_use {
711 let allocated = system::port::allocate_port_excluding(&claimed, port_in_use)?;
712 let reason = if privileged {
713 "port is privileged (requires root)".to_string()
714 } else if claimed_in_service {
715 format!(
716 "port {} is already claimed by another port in this service",
717 p.container_port
718 )
719 } else {
720 format!("port {} is already in use", p.container_port)
721 };
722 port_warnings.push(Warning::PortReassigned {
723 service_name: service_name.to_string(),
724 port_name: p.name.clone(),
725 original_port: p.container_port,
726 assigned_port: allocated,
727 reason,
728 });
729 allocated
730 } else {
731 p.container_port
732 }
733 };
734 claimed.insert(host);
735 resolved_ports.push((p.name.clone(), host));
736 }
737
738 let caddy_direct = selected_choices
747 .get("binding")
748 .map(|s| s == "direct")
749 .unwrap_or(false);
750 if WellKnownService::Caddy.matches(service_name)
751 && caddy_direct
752 && system::sysctl::rootless_can_bind_low_ports()
753 {
754 for (name, port) in resolved_ports.iter_mut() {
755 match name.as_str() {
756 "http" if *port == 8080 => *port = 80,
757 "https" if *port == 8443 => *port = 443,
758 _ => {}
759 }
760 }
761 }
762
763 let host_port = resolved_ports
766 .iter()
767 .find(|(name, _)| name.eq_ignore_ascii_case("http"))
768 .or_else(|| resolved_ports.first())
769 .map(|(_, p)| *p);
770
771 for (_, port) in &resolved_ports {
775 if port_in_use(*port) {
776 return Err(Error::PortConflict { port: *port });
777 }
778 }
779
780 let blue_green =
787 reg_service.def.service.deploy == registry::service_def::DeployStrategy::BlueGreen;
788 if blue_green {
789 let primary = resolved_ports
790 .iter()
791 .find(|(name, _)| name.eq_ignore_ascii_case("http"))
792 .or_else(|| resolved_ports.first())
793 .map(|(name, port)| (name.clone(), *port));
794 if let Some((name, blue_port)) = primary {
795 let green_key = format!("{}_green", name.to_ascii_lowercase());
796 let green_port = match port_overrides.get(&green_key) {
797 Some(pinned) => *pinned,
798 None => {
799 let p = system::port::allocate_port_excluding(&claimed, port_in_use)?;
800 claimed.insert(p);
801 p
802 }
803 };
804 resolved_ports.push((format!("{name}_blue"), blue_port));
805 resolved_ports.push((format!("{name}_green"), green_port));
806 }
807 }
808
809 let home_dir = service_home(service_name)?;
810 let quadlet_path = quadlet_dir()?;
811
812 let installed_now = list_installed().unwrap_or_default();
816 let authelia_installed =
817 find_installed_provider(&installed_now, Capability::OidcProvider).is_some();
818 let caddy_installed =
819 find_installed_provider(&installed_now, Capability::ReverseProxy).is_some();
820 let inbucket_installed =
821 find_installed_provider(&installed_now, Capability::SmtpRelay).is_some();
822 let metrics_store =
823 find_installed_provider(&installed_now, Capability::MetricsStore).map(|s| s.name.clone());
824
825 let provides_auth_infra = reg_service
831 .def
832 .capabilities
833 .provides
834 .iter()
835 .any(|c| matches!(c, Capability::OidcProvider | Capability::ReverseProxy));
836 if enable_auth
837 && !provides_auth_infra
838 && !caddy_installed
839 && let Some(authelia) = find_installed_provider(&installed_now, Capability::OidcProvider)
840 && let Some(auth_url) = authelia.exposure.url()
841 {
842 return Err(Error::AuthRequiresReverseProxy {
843 service: service_name.to_string(),
844 auth_url: auth_url.to_string(),
845 });
846 }
847
848 let auth_bridge = auth_bridge::build(&auth_bridge::AuthBridgeParams {
852 service_name,
853 service_provides: ®_service.def.capabilities.provides,
854 enable_auth,
855 config: &config,
856 installed: &installed_now,
857 service_data: &home_dir,
858 })?;
859
860 let (extra_volumes, extra_env, extra_exec_start_pre, auth_bridge_steps) = match auth_bridge {
861 Some(b) => (b.volumes, b.env, b.exec_start_pre, b.steps),
862 None => (Vec::new(), BTreeMap::new(), Vec::new(), Vec::new()),
863 };
864
865 let has_smtp = enable_smtp
866 && reg_service.def.integrations.smtp
867 && !reg_service.def.mappings.smtp.is_empty()
868 && config.smtp.is_some();
869 let wants_metrics = reg_service
872 .def
873 .metrics
874 .as_ref()
875 .is_some_and(|m| !m.host_network)
876 || capability::def_provides(®_service.def, Capability::MetricsDashboard);
877 let extra_networks = resolve_extra_networks(
878 service_name,
879 enable_auth,
880 authelia_installed,
881 caddy_installed,
882 inbucket_installed,
883 url.is_some(),
884 has_smtp,
885 metrics_store.as_deref(),
886 wants_metrics,
887 );
888
889 let output = generate::generate_env(generate::GenerateEnvParams {
890 config: &config,
891 service_def: ®_service.def,
892 auth_kind,
893 host_port,
894 resolved_ports: &resolved_ports,
895 env_overrides,
896 exposure,
897 extra_env,
898 pre_built_ctx,
899 enable_smtp: has_smtp,
900 enabled_groups,
901 selected_choices,
902 existing_env_file: existing_env_file.as_deref(),
903 allow_unset_required,
904 })?;
905
906 let podman_args: Vec<String> = Vec::new();
907
908 let port_names: Vec<String> = resolved_ports.iter().map(|(n, _)| n.clone()).collect();
910
911 let active_color = match reg_service.def.service.deploy {
927 registry::service_def::DeployStrategy::BlueGreen => {
928 Some(registry::service_def::Color::Blue)
929 }
930 registry::service_def::DeployStrategy::Restart => None,
931 };
932 let install_metadata = Metadata {
933 registry: registry_name.to_string(),
934 url: url.map(str::to_string),
935 auth: auth_kind.cloned(),
936 provides: reg_service.def.capabilities.provides.clone(),
937 backup_enabled: enable_backup,
938 smtp_enabled: enable_smtp,
939 enabled_groups: enabled_groups.iter().cloned().collect(),
940 selected_choices: selected_choices.clone(),
941 runtime: reg_service.def.service.runtime.clone(),
942 active_color,
943 };
944
945 if reg_service.def.service.runtime == registry::service_def::Runtime::Native {
950 let tracked_envs = collect_static_envs(
951 ®_service.def,
952 &output.ctx,
953 enabled_groups,
954 selected_choices,
955 )?;
956 let allocated_ports = resolved_ports.clone();
957 let generated_secrets = collect_generated_secrets(®_service.def, env_overrides);
958
959 let native_blue_green =
965 reg_service.def.service.deploy == registry::service_def::DeployStrategy::BlueGreen;
966 let mut caddy_steps: Vec<Step> = Vec::new();
967 let mut native_warnings: Vec<Warning> = Vec::new();
968 if let Some(u) = url
969 && !exposure.is_tailscale()
970 {
971 let upstream_port = if native_blue_green {
972 resolved_ports
973 .iter()
974 .find(|(n, _)| n.eq_ignore_ascii_case("http_blue"))
975 .map(|(_, p)| *p)
976 } else {
977 host_port
978 };
979 if let Some(p) = upstream_port {
980 let (route_steps, route_warnings) = caddy_route_steps(
981 service_name,
982 u,
983 "host.containers.internal".to_string(),
984 p,
985 host_port,
986 caddy_installed,
987 caddy_https_port(&config),
988 )?;
989 caddy_steps = route_steps;
990 native_warnings.extend(route_warnings);
991 }
992 }
993
994 return build_native_add(NativeAddParams {
995 service_name,
996 reg_service: ®_service,
997 home_dir: &home_dir,
998 output,
999 install_metadata: &install_metadata,
1000 registry_name,
1001 url,
1002 tracked_envs,
1003 allocated_ports,
1004 generated_secrets,
1005 excluded_quadlets: excluded_quadlets(®_service.def, selected_choices),
1006 caddy_steps,
1007 warnings: native_warnings,
1008 });
1009 }
1010
1011 let excluded_quadlets = excluded_quadlets(®_service.def, selected_choices);
1012
1013 let bundle =
1015 generate::bundle::process_quadlet_bundle(&generate::bundle::ProcessBundleParams {
1016 service_dir: ®_service.service_dir,
1017 service_name,
1018 extra_networks: &extra_networks,
1019 extra_volumes: &extra_volumes,
1020 podman_args: &podman_args,
1021 extra_exec_start_pre: &extra_exec_start_pre,
1022 port_names: &port_names,
1023 excluded_quadlets: &excluded_quadlets,
1024 })?;
1025
1026 let mut warnings = Vec::new();
1028
1029 if let Some(ref reqs) = reg_service.def.requirements
1030 && let Some(total) = system::memory::total_ram_mb()
1031 {
1032 if total < reqs.ram.min {
1033 warnings.push(Warning::RamBelowMinimum {
1034 service_name: service_name.to_string(),
1035 min_mb: reqs.ram.min,
1036 available_mb: total,
1037 });
1038 } else if let Some(rec) = reqs.ram.recommended
1039 && total < rec
1040 {
1041 warnings.push(Warning::RamBelowRecommended {
1042 service_name: service_name.to_string(),
1043 recommended_mb: rec,
1044 available_mb: total,
1045 });
1046 }
1047 }
1048 warnings.extend(port_warnings);
1049
1050 let mut steps = Vec::new();
1052
1053 steps.push(Step::CreateDir(home_dir.clone()));
1055
1056 let env_content = output.env_file.content.clone();
1058
1059 for image in &bundle.images {
1061 steps.push(Step::PullImage {
1062 image: image.clone(),
1063 });
1064 }
1065
1066 let quadlet_files = if blue_green {
1072 deploy::expand_color_quadlets(bundle.quadlet_files, service_name)
1073 } else {
1074 bundle.quadlet_files
1075 };
1076 for file in quadlet_files {
1077 let link = file
1078 .path
1079 .file_name()
1080 .map(|n| quadlet_path.join(n))
1081 .ok_or_else(|| {
1082 Error::Bundle(format!("invalid quadlet path: {}", file.path.display()))
1083 })?;
1084 let target = file.path.clone();
1085 steps.push(Step::WriteFile(file));
1086 steps.push(Step::Symlink { link, target });
1087 }
1088
1089 let metadata_content = toml::to_string_pretty(&install_metadata)?;
1096 steps.push(Step::WriteFile(GeneratedFile {
1097 path: metadata_path(service_name)?,
1098 content: metadata_content,
1099 }));
1100
1101 if mode == PlanMode::Add && exposure.is_tailscale() {
1109 let svc_name = exposure.tailscale_svc_name().ok_or_else(|| {
1119 Error::InvalidServiceRef(format!(
1120 "tailscale exposure for '{service_name}' has a malformed URL — \
1121 expected `https://<service>-<host>.<tailnet>.ts.net/`"
1122 ))
1123 })?;
1124 let ts_ports = plan::tailscale_ports(®_service.def.ports, &resolved_ports, host_port);
1128 if !ts_ports.is_empty() {
1129 steps.push(Step::TailscaleSetup);
1130 steps.push(Step::TailscaleEnable {
1131 svc_name,
1132 ports: ts_ports,
1133 });
1134 }
1135 }
1136
1137 for file in bundle.config_files {
1139 steps.push(Step::WriteFile(file));
1140 }
1141
1142 for (src, dst) in bundle.files {
1146 steps.push(Step::CopyFile { src, dst });
1147 }
1148
1149 steps.push(Step::WriteFile(output.env_file));
1151
1152 for dir in &bundle.bind_mount_dirs {
1154 steps.push(Step::CreateDir(dir.clone()));
1155 }
1156
1157 steps.extend(auth_bridge_steps);
1161
1162 if mode == PlanMode::Add
1171 && let (
1172 Some(registry::service_def::AuthKind::Oidc),
1173 Some(config::schema::AuthCredentials::Authelia { .. }),
1174 ) = (auth_kind, config.auth.as_ref())
1175 {
1176 steps.extend(authelia::register_oidc_client(
1177 service_name,
1178 ®_service.def,
1179 url,
1180 &output.ctx,
1181 &quadlet_path,
1182 )?);
1183 }
1184
1185 if let Some(url) = url
1191 && !WellKnownService::Caddy.matches(service_name)
1192 && !exposure.is_tailscale()
1193 {
1194 let container_port = reg_service
1195 .def
1196 .ports
1197 .first()
1198 .map(|p| p.container_port)
1199 .unwrap_or(80);
1200 let primary_quadlet = reg_service
1201 .service_dir
1202 .join("quadlets")
1203 .join(format!("{service_name}.container"));
1204 let target_host = if blue_green {
1207 deploy::color_unit(service_name, registry::service_def::Color::Blue)
1208 } else {
1209 caddy::primary_container_name(&primary_quadlet, service_name)
1210 };
1211 let (route_steps, route_warnings) = caddy_route_steps(
1212 service_name,
1213 url,
1214 target_host,
1215 container_port,
1216 host_port,
1217 caddy_installed,
1218 caddy_https_port(&config),
1219 )?;
1220 steps.extend(route_steps);
1221 warnings.extend(route_warnings);
1222 }
1223
1224 if mode == PlanMode::Add {
1234 steps.extend(retroactive_network_joins(
1235 service_name,
1236 &quadlet_path,
1237 Some(repo_dir),
1238 ));
1239 }
1240
1241 if mode == PlanMode::Add {
1248 if let Some(store) = &metrics_store {
1249 let metrics_host_port = reg_service.def.metrics.as_ref().and_then(|m| {
1250 resolved_ports
1251 .iter()
1252 .find(|(n, _)| n == &m.port)
1253 .map(|(_, p)| *p)
1254 });
1255 if let Some(step) =
1256 metrics_bridge::scrape_target_step(store, ®_service.def, metrics_host_port)?
1257 {
1258 steps.push(step);
1259 }
1260 if capability::def_provides(®_service.def, Capability::MetricsDashboard)
1261 && let Some(port) = store_container_port(store)
1262 {
1263 steps.push(metrics_bridge::datasource_step(service_name, store, port)?);
1264 }
1265 }
1266 if capability::def_provides(®_service.def, Capability::MetricsStore) {
1267 steps.extend(retroactive_metrics_wiring(
1268 service_name,
1269 ®_service.def,
1270 &quadlet_path,
1271 ));
1272 }
1273 }
1274
1275 if WellKnownService::Caddy.matches(service_name) {
1282 let snippet_path = caddy::tls_snippet_path()?;
1283 if !snippet_path.exists() {
1284 let mode = acme_mode.cloned().unwrap_or(caddy::AcmeMode::Internal);
1285 steps.push(Step::WriteFile(GeneratedFile {
1286 path: snippet_path,
1287 content: mode.snippet(),
1288 }));
1289 }
1290 }
1291
1292 let manifest_path_for_svc = manifest::manifest_path(service_name)?;
1301 let env_filename = std::ffi::OsStr::new(".env");
1302 let mut manifest_entries: Vec<manifest::ManifestEntry> = Vec::new();
1303 for step in &steps {
1304 if let Step::WriteFile(file) = step {
1305 if file.path == manifest_path_for_svc {
1306 continue;
1307 }
1308 if file.path.file_name() == Some(env_filename) {
1309 continue;
1310 }
1311 if auth_bridge::is_hook_rewritten(&file.path) {
1317 continue;
1318 }
1319 manifest_entries.push(manifest::ManifestEntry {
1320 path: file.path.clone(),
1321 sha256: manifest::hash_bytes(file.content.as_bytes()),
1322 });
1323 }
1324 }
1325 let tracked_envs = collect_static_envs(
1333 ®_service.def,
1334 &output.ctx,
1335 enabled_groups,
1336 selected_choices,
1337 )?;
1338 let manifest_envs: Vec<manifest::EnvEntry> = tracked_envs
1339 .iter()
1340 .map(|t| manifest::EnvEntry {
1341 key: t.key.clone(),
1342 value: t.value.clone(),
1343 })
1344 .collect();
1345 steps.push(Step::WriteFile(GeneratedFile {
1346 path: manifest_path_for_svc,
1347 content: manifest::format(&manifest_entries, &manifest_envs),
1348 }));
1349
1350 steps.push(Step::DaemonReload);
1352 let start_unit = if blue_green {
1356 deploy::color_unit(service_name, registry::service_def::Color::Blue)
1357 } else {
1358 service_name.to_string()
1359 };
1360 steps.push(Step::StartService { unit: start_unit });
1361
1362 let allocated_ports: Vec<(String, u16)> = resolved_ports.clone();
1364
1365 let mut generated_secrets: Vec<String> = reg_service
1367 .def
1368 .env
1369 .iter()
1370 .filter(|e| !env_overrides.contains_key(&e.name))
1371 .flat_map(|e| generate::extract_secret_refs(&e.value))
1372 .collect();
1373 generated_secrets.sort();
1375 generated_secrets.dedup();
1376
1377 Ok(AddResult {
1378 steps,
1379 warnings,
1380 repo_url: registry_name.to_string(),
1381 allocated_ports,
1382 generated_secrets,
1383 env_content,
1384 url: url.map(|u| u.to_string()),
1385 tracked_envs,
1386 })
1387}
1388
1389fn collect_generated_secrets(
1392 def: ®istry::service_def::ServiceDef,
1393 env_overrides: &BTreeMap<String, String>,
1394) -> Vec<String> {
1395 let mut out: Vec<String> = def
1396 .env
1397 .iter()
1398 .filter(|e| !env_overrides.contains_key(&e.name))
1399 .flat_map(|e| generate::extract_secret_refs(&e.value))
1400 .collect();
1401 out.sort();
1402 out.dedup();
1403 out
1404}
1405
1406struct NativeAddParams<'a> {
1408 service_name: &'a str,
1409 reg_service: &'a registry::RegistryService,
1410 home_dir: &'a Path,
1411 output: generate::EnvOutput,
1412 install_metadata: &'a Metadata,
1413 registry_name: &'a str,
1414 url: Option<&'a str>,
1415 tracked_envs: Vec<TrackedEnv>,
1416 allocated_ports: Vec<(String, u16)>,
1417 generated_secrets: Vec<String>,
1418 excluded_quadlets: Vec<String>,
1422 caddy_steps: Vec<Step>,
1426 warnings: Vec<Warning>,
1429}
1430
1431fn excluded_quadlets(
1435 def: ®istry::service_def::ServiceDef,
1436 selected_choices: &BTreeMap<String, String>,
1437) -> Vec<String> {
1438 let mut all_claimed: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
1439 let mut selected: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
1440 for choice in &def.choices {
1441 let picked = selected_choices
1442 .get(&choice.name)
1443 .unwrap_or(&choice.default);
1444 for option in &choice.options {
1445 for q in &option.quadlets {
1446 all_claimed.insert(q.clone());
1447 if &option.name == picked {
1448 selected.insert(q.clone());
1449 }
1450 }
1451 }
1452 }
1453 all_claimed.difference(&selected).cloned().collect()
1454}
1455
1456fn build_native_add(p: NativeAddParams<'_>) -> Result<AddResult> {
1462 let NativeAddParams {
1463 service_name,
1464 reg_service,
1465 home_dir,
1466 output,
1467 install_metadata,
1468 registry_name,
1469 url,
1470 tracked_envs,
1471 allocated_ports,
1472 generated_secrets,
1473 excluded_quadlets,
1474 caddy_steps,
1475 warnings,
1476 } = p;
1477
1478 let run = reg_service.def.service.run.as_ref().ok_or_else(|| {
1479 Error::Bundle(format!(
1480 "native service '{service_name}' is missing its `run` command"
1481 ))
1482 })?;
1483 let build = reg_service.def.service.build.as_ref();
1484
1485 let env_content = output.env_file.content.clone();
1486 let source_dir = reg_service.service_dir.clone();
1487 let mut steps = Vec::new();
1488
1489 steps.push(Step::CreateDir(home_dir.to_path_buf()));
1494 steps.push(Step::CreateDir(home_dir.join("data")));
1495
1496 let blue_green =
1497 reg_service.def.service.deploy == registry::service_def::DeployStrategy::BlueGreen;
1498
1499 steps.push(Step::WriteFile(GeneratedFile {
1502 path: metadata_path(service_name)?,
1503 content: toml::to_string_pretty(install_metadata)?,
1504 }));
1505 steps.push(Step::WriteFile(output.env_file));
1506
1507 let description = reg_service.def.service.description.as_str();
1508 if blue_green {
1509 let primary = allocated_ports
1515 .iter()
1516 .find(|(n, _)| n.eq_ignore_ascii_case("http"))
1517 .or_else(|| allocated_ports.first())
1518 .map(|(n, _)| n.clone())
1519 .ok_or_else(|| {
1520 Error::Bundle(format!(
1521 "blue/green native '{service_name}' has no port to route"
1522 ))
1523 })?;
1524 let port_var = format!("SERVICE_PORT_{}", primary.to_uppercase());
1525 let home_str = home_dir.to_string_lossy().into_owned();
1526 for color in [
1527 registry::service_def::Color::Blue,
1528 registry::service_def::Color::Green,
1529 ] {
1530 let slot = home_dir.join("colors").join(color.as_str());
1531 let slot_str = slot.to_string_lossy().into_owned();
1532 let port = allocated_ports
1533 .iter()
1534 .find(|(n, _)| *n == format!("{}_{}", primary.to_ascii_lowercase(), color))
1535 .map(|(_, p)| *p)
1536 .ok_or_else(|| {
1537 Error::Bundle(format!(
1538 "blue/green native '{service_name}' missing the {color} port"
1539 ))
1540 })?;
1541 steps.push(Step::SyncDir {
1543 src: source_dir.clone(),
1544 dst: slot.clone(),
1545 });
1546 if let Some(command) = build {
1547 steps.push(Step::Build {
1548 dir: slot.clone(),
1549 command: command.clone(),
1550 });
1551 }
1552 let unit_name = format!("{}.service", deploy::color_unit(service_name, color));
1553 let unit_path = home_dir.join(&unit_name);
1554 steps.push(Step::WriteFile(GeneratedFile {
1555 path: unit_path.clone(),
1556 content: deploy::native_color_unit(&deploy::NativeColorUnit {
1557 description,
1558 color,
1559 workdir: &slot_str,
1560 home: &home_str,
1561 port_var: &port_var,
1562 port,
1563 run,
1564 }),
1565 }));
1566 steps.push(Step::Symlink {
1567 link: systemd_user_dir()?.join(&unit_name),
1568 target: unit_path,
1569 });
1570 }
1571 } else {
1572 if let Some(command) = build {
1574 steps.push(Step::Build {
1575 dir: source_dir.clone(),
1576 command: command.clone(),
1577 });
1578 }
1579 let unit_name = format!("{service_name}.service");
1582 let unit_path = home_dir.join(&unit_name);
1583 steps.push(Step::WriteFile(GeneratedFile {
1584 path: unit_path.clone(),
1585 content: native_unit(home_dir, &source_dir, run, description),
1586 }));
1587 steps.push(Step::Symlink {
1588 link: systemd_user_dir()?.join(&unit_name),
1589 target: unit_path,
1590 });
1591 }
1592
1593 let mut quadlet_units: Vec<String> = Vec::new();
1600 if source_dir.join("quadlets").is_dir() {
1601 let port_names: Vec<String> = allocated_ports.iter().map(|(n, _)| n.clone()).collect();
1604 let bundle =
1605 generate::bundle::process_quadlet_bundle(&generate::bundle::ProcessBundleParams {
1606 service_dir: &source_dir,
1607 service_name,
1608 extra_networks: &[],
1609 extra_volumes: &[],
1610 podman_args: &[],
1611 extra_exec_start_pre: &[],
1612 port_names: &port_names,
1613 excluded_quadlets: &excluded_quadlets,
1614 })?;
1615 for image in &bundle.images {
1616 steps.push(Step::PullImage {
1617 image: image.clone(),
1618 });
1619 }
1620 for dir in &bundle.bind_mount_dirs {
1621 steps.push(Step::CreateDir(dir.clone()));
1622 }
1623 let quadlet_path = quadlet_dir()?;
1624 for file in bundle.quadlet_files {
1625 let fname = file
1626 .path
1627 .file_name()
1628 .ok_or_else(|| {
1629 Error::Bundle(format!("invalid quadlet path: {}", file.path.display()))
1630 })?
1631 .to_os_string();
1632 if let Some(stem) = fname.to_string_lossy().strip_suffix(".container") {
1633 quadlet_units.push(stem.to_string());
1634 }
1635 let link = quadlet_path.join(&fname);
1636 let target = file.path.clone();
1637 steps.push(Step::WriteFile(file));
1638 steps.push(Step::Symlink { link, target });
1639 }
1640 }
1641
1642 steps.push(Step::DaemonReload);
1643 for unit in &quadlet_units {
1645 steps.push(Step::StartService { unit: unit.clone() });
1646 }
1647 let app_unit = if blue_green {
1650 deploy::color_unit(service_name, registry::service_def::Color::Blue)
1651 } else {
1652 service_name.to_string()
1653 };
1654 steps.push(Step::EnableService {
1660 unit: app_unit.clone(),
1661 });
1662 steps.push(Step::StartService { unit: app_unit });
1663
1664 steps.extend(caddy_steps);
1667
1668 Ok(AddResult {
1669 steps,
1670 warnings,
1671 repo_url: registry_name.to_string(),
1672 allocated_ports,
1673 generated_secrets,
1674 env_content,
1675 url: url.map(|u| u.to_string()),
1676 tracked_envs,
1677 })
1678}
1679
1680fn native_unit(home_dir: &Path, source_dir: &Path, run: &str, description: &str) -> String {
1685 let home = home_dir.display();
1686 let source = source_dir.display();
1687 format!(
1696 "[Unit]\n\
1697 Description={description}\n\
1698 After=network.target\n\
1699 \n\
1700 [Service]\n\
1701 Type=simple\n\
1702 WorkingDirectory={source}\n\
1703 EnvironmentFile={home}/.env\n\
1704 Environment=SERVICE_HOME={home}\n\
1705 Environment=PATH=%h/.local/bin:%h/.cargo/bin:%h/.bun/bin:%h/.deno/bin:%h/go/bin:/usr/local/bin:/usr/bin:/bin\n\
1706 ExecStart=/bin/sh -c 'exec {run}'\n\
1707 Restart=always\n\
1708 RestartSec=5\n\
1709 \n\
1710 [Install]\n\
1711 WantedBy=default.target\n",
1712 )
1713}
1714
1715pub fn quadlet_belongs_to(filename: &str, service_name: &str, all_service_names: &[&str]) -> bool {
1724 if !filename.starts_with(service_name) {
1725 return false;
1726 }
1727 let rest = &filename[service_name.len()..];
1728 if rest.starts_with('.') {
1729 return true;
1730 }
1731 if !rest.starts_with('-') {
1732 return false;
1733 }
1734 !all_service_names.iter().any(|&other| {
1738 other.len() > service_name.len()
1739 && other.starts_with(service_name)
1740 && filename.starts_with(other)
1741 && filename[other.len()..].starts_with(['.', '-'])
1742 })
1743}
1744
1745#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
1747#[serde(rename_all = "snake_case")]
1748pub enum RemoveMode {
1749 #[default]
1750 Preserve,
1755 Purge,
1757}
1758
1759pub fn remove_service(service_name: &str, mode: RemoveMode) -> Result<RemoveResult> {
1761 let installed_owned = build_installed_from_metadata(service_name)
1764 .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
1765 let installed = &installed_owned;
1766
1767 if let Ok(Some(meta)) = metadata::load_metadata(service_name)
1772 && meta.runtime == registry::service_def::Runtime::Native
1773 {
1774 let url = installed.exposure.url().map(|s| s.to_string());
1775 return remove_native_service(service_name, mode, url);
1776 }
1777
1778 let quadlet_path = quadlet_dir()?;
1781 let mut steps = Vec::new();
1782 let mut volume_names = Vec::new();
1783 let mut networks: Vec<String> = Vec::new();
1784 let mut has_named_volumes = false;
1785 let name_pool = scan_managed_services().unwrap_or_default();
1789 let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
1790
1791 if let Some(svc_name) = installed.exposure.tailscale_svc_name() {
1803 steps.push(Step::TailscaleDisable { svc_name });
1804 }
1805
1806 if quadlet_path.is_dir()
1807 && let Ok(entries) = std::fs::read_dir(&quadlet_path)
1808 {
1809 for entry in entries.flatten() {
1810 let file_name = entry.file_name();
1811 let name = file_name.to_string_lossy();
1812 if !quadlet_belongs_to(&name, service_name, &all_names) {
1815 continue;
1816 }
1817 if name.ends_with(".container") {
1819 let unit = name.trim_end_matches(".container").to_string();
1820 steps.push(Step::StopService { unit });
1821 }
1822 if name.ends_with(".network") {
1823 let net = name.trim_end_matches(".network").to_string();
1826 steps.push(Step::StopService {
1827 unit: format!("{net}-network"),
1828 });
1829 networks.push(net);
1830 }
1831 if name.ends_with(".volume") {
1832 has_named_volumes = true;
1833 if matches!(mode, RemoveMode::Purge) {
1834 let vol = name.trim_end_matches(".volume").to_string();
1835 volume_names.push(format!("systemd-{vol}"));
1837 }
1838 }
1839 steps.push(Step::RemoveFile(entry.path()));
1840 }
1841 }
1842
1843 let had_caddy_route = matches!(
1850 installed.exposure,
1851 Exposure::Internal { .. } | Exposure::Public { .. }
1852 );
1853 if !WellKnownService::Caddy.matches(service_name) && had_caddy_route {
1854 let caddyfile_path = caddy::caddyfile_path()?;
1855 if caddyfile_path.exists() {
1856 let existing =
1857 std::fs::read_to_string(&caddyfile_path).map_err(|source| Error::FileRead {
1858 path: caddyfile_path.clone(),
1859 source,
1860 })?;
1861 let updated = caddy::remove_route(&existing, service_name);
1862 if updated != existing {
1863 steps.push(Step::WriteFile(GeneratedFile {
1864 path: caddyfile_path,
1865 content: updated.clone(),
1866 }));
1867 if !updated.trim().is_empty() {
1870 steps.push(Step::ReloadCaddy);
1871 }
1872 }
1873 }
1874 }
1875
1876 if !WellKnownService::Authelia.matches(service_name)
1877 && matches!(
1878 installed.auth_kind,
1879 Some(registry::service_def::AuthKind::Oidc)
1880 )
1881 {
1882 steps.extend(authelia::unregister_oidc_client(service_name)?);
1883 }
1884
1885 let installed_all = list_installed().unwrap_or_default();
1891 for store in installed_all
1892 .iter()
1893 .filter(|s| installed_provides(s, Capability::MetricsStore))
1894 {
1895 if store.name != service_name
1896 && let Ok(target) = metrics_bridge::target_file_path(&store.name, service_name)
1897 && target.exists()
1898 {
1899 steps.push(Step::RemoveFile(target));
1900 }
1901 }
1902 if installed.provides.contains(&Capability::MetricsStore) {
1903 for dash in installed_all
1904 .iter()
1905 .filter(|s| installed_provides(s, Capability::MetricsDashboard))
1906 {
1907 if dash.name == service_name {
1908 continue;
1909 }
1910 if let Ok(ds) = metrics_bridge::datasource_file_path(&dash.name, service_name)
1911 && ds.exists()
1912 {
1913 steps.push(Step::RemoveFile(ds));
1914 steps.push(Step::RestartService {
1915 unit: dash.name.clone(),
1916 });
1917 }
1918 }
1919 }
1920
1921 steps.push(Step::DaemonReload);
1923
1924 for net in networks {
1932 steps.push(Step::RemoveNetwork { name: net });
1933 }
1934
1935 match mode {
1936 RemoveMode::Purge => {
1937 for vol_name in volume_names {
1939 steps.push(Step::RemoveVolume { name: vol_name });
1940 }
1941 steps.push(Step::RemoveDir(service_home(service_name)?));
1943 }
1944 RemoveMode::Preserve => {
1945 let home = service_home(service_name)?;
1949 let (data, ephemeral) = crate::data::classify::classify_home_dir(&home)?;
1950 for path in ephemeral {
1951 match std::fs::metadata(&path) {
1952 Ok(m) if m.is_dir() => steps.push(Step::RemoveDir(path)),
1953 Ok(_) => steps.push(Step::RemoveFile(path)),
1954 Err(_) => steps.push(Step::RemoveFile(path)),
1958 }
1959 }
1960 if data.is_empty() && !has_named_volumes && home.exists() {
1968 steps.push(Step::RemoveDir(home));
1969 }
1970 }
1971 }
1972
1973 let url = installed.exposure.url().map(|s| s.to_string());
1974
1975 Ok(RemoveResult {
1976 steps,
1977 service_name: service_name.to_string(),
1978 url,
1979 })
1980}
1981
1982fn remove_native_service(
1987 service_name: &str,
1988 mode: RemoveMode,
1989 url: Option<String>,
1990) -> Result<RemoveResult> {
1991 let home = service_home(service_name)?;
1992 let unit_dir = systemd_user_dir()?;
1996 let unit_names: Vec<String> = [
1997 format!("{service_name}.service"),
1998 format!("{service_name}-blue.service"),
1999 format!("{service_name}-green.service"),
2000 ]
2001 .into_iter()
2002 .filter(|u| unit_dir.join(u).exists())
2003 .collect();
2004 let mut steps = Vec::new();
2005
2006 let mut aux_container_files: Vec<String> = Vec::new();
2012 if let Ok(qdir) = quadlet_dir() {
2013 let names = scan_managed_services().unwrap_or_default();
2014 let all: Vec<&str> = names.iter().map(|s| s.as_str()).collect();
2015 if let Ok(entries) = std::fs::read_dir(&qdir) {
2016 for entry in entries.flatten() {
2017 let fname = entry.file_name().to_string_lossy().into_owned();
2018 if let Some(stem) = fname.strip_suffix(".container")
2019 && quadlet_belongs_to(&fname, service_name, &all)
2020 {
2021 steps.push(Step::StopService {
2022 unit: stem.to_string(),
2023 });
2024 steps.push(Step::RemoveFile(qdir.join(&fname)));
2025 aux_container_files.push(fname);
2026 }
2027 }
2028 }
2029 }
2030
2031 for unit_name in &unit_names {
2032 let bare = unit_name.trim_end_matches(".service").to_string();
2033 steps.push(Step::DisableService { unit: bare.clone() });
2036 steps.push(Step::StopService { unit: bare });
2037 steps.push(Step::RemoveFile(unit_dir.join(unit_name)));
2038 }
2039 steps.push(Step::DaemonReload);
2040
2041 match mode {
2042 RemoveMode::Purge => steps.push(Step::RemoveDir(home)),
2043 RemoveMode::Preserve => {
2044 let mut ephemeral: Vec<String> = vec!["bin".into(), ".env".into(), "colors".into()];
2049 ephemeral.extend(unit_names.iter().cloned());
2050 for child in &ephemeral {
2051 let p = home.join(child);
2052 match std::fs::metadata(&p) {
2053 Ok(m) if m.is_dir() => steps.push(Step::RemoveDir(p)),
2054 Ok(_) => steps.push(Step::RemoveFile(p)),
2055 Err(_) => {} }
2057 }
2058 for f in &aux_container_files {
2059 steps.push(Step::RemoveFile(home.join(f)));
2060 }
2061 }
2062 }
2063
2064 Ok(RemoveResult {
2065 steps,
2066 service_name: service_name.to_string(),
2067 url,
2068 })
2069}
2070
2071#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2073#[serde(rename_all = "snake_case")]
2074pub enum Lifecycle {
2075 Start,
2076 Stop,
2077}
2078
2079pub fn lifecycle_steps(service_name: &str, action: Lifecycle) -> Result<Vec<Step>> {
2088 build_installed_from_metadata(service_name)
2090 .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
2091
2092 if matches!(
2095 metadata::load_metadata(service_name),
2096 Ok(Some(m)) if m.runtime == registry::service_def::Runtime::Native
2097 ) {
2098 let unit = service_name.to_string();
2099 return Ok(vec![match action {
2100 Lifecycle::Start => Step::StartService { unit },
2101 Lifecycle::Stop => Step::StopService { unit },
2102 }]);
2103 }
2104
2105 let mut units = service_container_units(service_name)?;
2106 match action {
2107 Lifecycle::Stop => units.sort_by_key(|u| u != service_name),
2109 Lifecycle::Start => units.sort_by_key(|u| u == service_name),
2111 }
2112
2113 Ok(units
2114 .into_iter()
2115 .map(|unit| match action {
2116 Lifecycle::Start => Step::StartService { unit },
2117 Lifecycle::Stop => Step::StopService { unit },
2118 })
2119 .collect())
2120}
2121
2122fn service_container_units(service_name: &str) -> Result<Vec<String>> {
2126 let quadlet_path = quadlet_dir()?;
2127 let name_pool = scan_managed_services().unwrap_or_default();
2128 let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
2129
2130 let mut units = Vec::new();
2131 if quadlet_path.is_dir()
2132 && let Ok(entries) = std::fs::read_dir(&quadlet_path)
2133 {
2134 for entry in entries.flatten() {
2135 let file_name = entry.file_name();
2136 let name = file_name.to_string_lossy();
2137 if !quadlet_belongs_to(&name, service_name, &all_names) {
2138 continue;
2139 }
2140 if name.ends_with(".container") {
2141 units.push(name.trim_end_matches(".container").to_string());
2142 }
2143 }
2144 }
2145 Ok(units)
2146}
2147
2148pub struct RecordPendingParams<'a> {
2150 pub service_name: &'a str,
2151 pub auth_kind: Option<registry::service_def::AuthKind>,
2152 pub registry_name: &'a str,
2153 pub allocated_ports: &'a [(String, u16)],
2154 pub repo_dir: &'a Path,
2155 pub exposure: &'a Exposure,
2162}
2163
2164pub fn record_pending(params: RecordPendingParams<'_>) -> Result<()> {
2171 let paths = ConfigPaths::resolve()?;
2172 paths.ensure_dirs()?;
2173 let mut config = config::load_or_default(&paths.config_file)?;
2174
2175 if WellKnownService::Authelia.matches(params.service_name) {
2180 config.auth = Some(authelia::auth_config(
2181 params.allocated_ports,
2182 params.exposure.url(),
2183 )?);
2184 config::save_config(&paths.config_file, &config)?;
2185 }
2186
2187 Ok(())
2188}
2189
2190pub fn finalize_remove(service_name: &str) -> Result<()> {
2197 let paths = ConfigPaths::resolve()?;
2198 let mut config = config::load_or_default(&paths.config_file)?;
2199
2200 if WellKnownService::Authelia.matches(service_name)
2201 && let Some(auth) = &config.auth
2202 && auth.provider_name() == "authelia"
2203 {
2204 config.auth = None;
2205 config::save_config(&paths.config_file, &config)?;
2206 }
2207
2208 Ok(())
2209}
2210
2211const SENSITIVE_TEMPLATE_REFS: &[&str] = &[
2230 "{{secret.",
2231 "{{auth.client_id",
2232 "{{auth.client_secret",
2233 "{{smtp.username",
2234 "{{smtp.password",
2235];
2236
2237fn is_static_template(value: &str) -> bool {
2238 !SENSITIVE_TEMPLATE_REFS.iter().any(|s| value.contains(s))
2239}
2240
2241fn collect_static_envs(
2257 service_def: ®istry::service_def::ServiceDef,
2258 ctx: &BTreeMap<String, String>,
2259 enabled_groups: &std::collections::BTreeSet<String>,
2260 selected_choices: &BTreeMap<String, String>,
2261) -> Result<Vec<plan::TrackedEnv>> {
2262 use registry::service_def::EnvKind;
2263 let mut out: Vec<plan::TrackedEnv> = Vec::new();
2264 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
2265 let push = |name: &str,
2266 value_template: &str,
2267 kind: EnvKind,
2268 prompt: Option<String>,
2269 out: &mut Vec<plan::TrackedEnv>,
2270 seen: &mut std::collections::HashSet<String>|
2271 -> Result<()> {
2272 if !is_static_template(value_template) {
2273 return Ok(());
2274 }
2275 if !seen.insert(name.to_string()) {
2276 return Ok(());
2277 }
2278 let value = generate::template::render(value_template, ctx)?;
2279 out.push(plan::TrackedEnv {
2280 key: name.to_string(),
2281 value,
2282 kind,
2283 prompt,
2284 });
2285 Ok(())
2286 };
2287 for env in &service_def.env {
2288 push(
2289 &env.name,
2290 &env.value,
2291 env.kind.clone(),
2292 env.prompt.clone(),
2293 &mut out,
2294 &mut seen,
2295 )?;
2296 }
2297 for group in &service_def.env_groups {
2298 if !enabled_groups.contains(&group.name) {
2299 continue;
2300 }
2301 for env in &group.env {
2302 push(
2303 &env.name,
2304 &env.value,
2305 env.kind.clone(),
2306 env.prompt.clone(),
2307 &mut out,
2308 &mut seen,
2309 )?;
2310 }
2311 }
2312 for choice in &service_def.choices {
2315 let selected = selected_choices
2316 .get(&choice.name)
2317 .unwrap_or(&choice.default);
2318 let Some(option) = choice.options.iter().find(|o| &o.name == selected) else {
2319 continue;
2320 };
2321 for env in &option.env {
2322 push(
2323 &env.name,
2324 &env.value,
2325 env.kind.clone(),
2326 env.prompt.clone(),
2327 &mut out,
2328 &mut seen,
2329 )?;
2330 }
2331 }
2332 if service_def.integrations.smtp && ctx.contains_key("smtp.host") {
2338 for (env_name, value_template) in &service_def.mappings.smtp {
2339 push(
2340 env_name,
2341 value_template,
2342 EnvKind::Default,
2343 None,
2344 &mut out,
2345 &mut seen,
2346 )?;
2347 }
2348 }
2349 if ctx.contains_key("auth.client_id") {
2350 for (env_name, value_template) in &service_def.mappings.auth {
2351 push(
2352 env_name,
2353 value_template,
2354 EnvKind::Default,
2355 None,
2356 &mut out,
2357 &mut seen,
2358 )?;
2359 }
2360 }
2361 Ok(out)
2362}
2363
2364pub fn orphan_purge_steps(svc: &data::ServiceData) -> Vec<Step> {
2365 let mut steps = Vec::new();
2366
2367 let mut had_quadlet = false;
2373 let mut networks: Vec<String> = Vec::new();
2374 if let Ok(qdir) = quadlet_dir()
2375 && qdir.is_dir()
2376 && let Ok(entries) = std::fs::read_dir(&qdir)
2377 {
2378 let name_pool = scan_managed_services().unwrap_or_default();
2379 let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
2380 for entry in entries.flatten() {
2381 let file_name = entry.file_name();
2382 let name = file_name.to_string_lossy();
2383 if !quadlet_belongs_to(&name, &svc.service, &all_names) {
2384 continue;
2385 }
2386 if name.ends_with(".container") {
2390 let unit = name.trim_end_matches(".container").to_string();
2391 steps.push(Step::StopService { unit });
2392 } else if name.ends_with(".network") {
2393 let net = name.trim_end_matches(".network").to_string();
2394 steps.push(Step::StopService {
2395 unit: format!("{net}-network"),
2396 });
2397 networks.push(net);
2398 } else if name.ends_with(".volume") {
2399 let unit = format!("{}-volume", name.trim_end_matches(".volume"));
2400 steps.push(Step::StopService { unit });
2401 }
2402 steps.push(Step::RemoveFile(entry.path()));
2403 had_quadlet = true;
2404 }
2405 }
2406 if had_quadlet {
2407 steps.push(Step::DaemonReload);
2408 }
2409 for net in networks {
2411 steps.push(Step::RemoveNetwork { name: net });
2412 }
2413
2414 for path in &svc.data_paths {
2415 if path.is_dir() {
2416 steps.push(Step::RemoveDir(path.clone()));
2417 } else {
2418 steps.push(Step::RemoveFile(path.clone()));
2419 }
2420 }
2421 if svc.home_dir.exists() {
2422 steps.push(Step::RemoveDir(svc.home_dir.clone()));
2423 }
2424 for v in &svc.volumes {
2425 steps.push(Step::RemoveVolume {
2426 name: v.name.clone(),
2427 });
2428 }
2429 steps
2430}
2431
2432pub fn reset() -> Result<ResetResult> {
2434 let mut steps = Vec::new();
2435
2436 let managed_names = scan_managed_services().unwrap_or_default();
2441
2442 for svc in list_installed().unwrap_or_default() {
2449 if let Some(svc_name) = svc.exposure.tailscale_svc_name() {
2450 steps.push(Step::TailscaleDisable { svc_name });
2451 }
2452 }
2453
2454 let quadlet_path = quadlet_dir()?;
2456 let all_names: Vec<&str> = managed_names.iter().map(|s| s.as_str()).collect();
2457 let mut networks: Vec<String> = Vec::new();
2458 if quadlet_path.is_dir()
2459 && let Ok(entries) = std::fs::read_dir(&quadlet_path)
2460 {
2461 for entry in entries.flatten() {
2462 let file_name = entry.file_name();
2463 let name = file_name.to_string_lossy();
2464 let is_ryra_file = managed_names
2468 .iter()
2469 .any(|svc| quadlet_belongs_to(&name, svc, &all_names));
2470 if !is_ryra_file {
2471 continue;
2472 }
2473 if name.ends_with(".container") {
2474 let unit = name.trim_end_matches(".container").to_string();
2475 steps.push(Step::StopService { unit });
2476 }
2477 if name.ends_with(".network") {
2478 let net = name.trim_end_matches(".network").to_string();
2479 steps.push(Step::StopService {
2480 unit: format!("{net}-network"),
2481 });
2482 networks.push(net);
2483 }
2484 if name.ends_with(".volume") {
2485 let vol = name.trim_end_matches(".volume").to_string();
2486 steps.push(Step::StopService {
2493 unit: format!("{vol}-volume"),
2494 });
2495 }
2496 steps.push(Step::RemoveFile(entry.path()));
2497 }
2498 }
2499
2500 let user_unit_dir = systemd_user_dir()?;
2506 if let Ok(root) = service_data_root()
2507 && let Ok(entries) = std::fs::read_dir(&root)
2508 {
2509 for entry in entries.flatten() {
2510 let Some(name) = entry.file_name().to_str().map(str::to_string) else {
2511 continue;
2512 };
2513 if matches!(
2514 metadata::load_metadata(&name),
2515 Ok(Some(m)) if m.runtime == registry::service_def::Runtime::Native
2516 ) {
2517 steps.push(Step::StopService { unit: name.clone() });
2518 steps.push(Step::RemoveFile(
2519 user_unit_dir.join(format!("{name}.service")),
2520 ));
2521 }
2522 }
2523 }
2524
2525 steps.push(Step::DaemonReload);
2527
2528 for net in networks {
2531 steps.push(Step::RemoveNetwork { name: net });
2532 }
2533
2534 let mut seen_volumes = std::collections::BTreeSet::new();
2540 for svc in data::enumerate_all().unwrap_or_default() {
2541 for vol in svc.volumes {
2542 if seen_volumes.insert(vol.name.clone()) {
2543 steps.push(Step::RemoveVolume { name: vol.name });
2544 }
2545 }
2546 }
2547
2548 let data_root = service_data_root()?;
2554 if data_root.exists() {
2555 steps.push(Step::RemoveDir(data_root));
2556 }
2557
2558 Ok(ResetResult { steps })
2559}
2560
2561pub fn finalize_reset() -> Result<()> {
2563 let paths = ConfigPaths::resolve()?;
2564 if paths.config_dir.exists() {
2565 std::fs::remove_dir_all(&paths.config_dir).map_err(|source| Error::FileWrite {
2566 path: paths.config_dir,
2567 source,
2568 })?;
2569 }
2570 Ok(())
2571}
2572
2573pub fn status() -> config::status::RyraStatus {
2579 let paths = match ConfigPaths::resolve() {
2580 Ok(p) => p,
2581 Err(_) => return config::status::RyraStatus::NotInitialized,
2582 };
2583
2584 let has_quadlets = scan_managed_services()
2585 .map(|n| !n.is_empty())
2586 .unwrap_or(false);
2587
2588 let config = match config::load_config(&paths.config_file) {
2589 Ok(c) => c,
2590 Err(Error::ConfigNotFound(_)) if has_quadlets => config::schema::Config::default(),
2591 Err(Error::ConfigNotFound(_)) => return config::status::RyraStatus::NotInitialized,
2592 Err(e) => return config::status::RyraStatus::Error(e.to_string()),
2593 };
2594
2595 config::status::RyraStatus::Initialized(config::status::StatusInfo::from_config(
2596 paths.config_file,
2597 &config,
2598 ))
2599}
2600
2601pub fn is_service_installed(name: &str) -> bool {
2609 let Ok(Some(meta)) = metadata::load_metadata(name) else {
2613 return false;
2614 };
2615 match meta.runtime {
2616 registry::service_def::Runtime::Native => systemd_user_dir()
2619 .map(|d| {
2620 d.join(format!("{name}.service")).exists()
2621 || d.join(format!("{name}-blue.service")).exists()
2622 || d.join(format!("{name}-green.service")).exists()
2623 })
2624 .unwrap_or(false),
2625 registry::service_def::Runtime::Podman => scan_managed_services()
2626 .map(|names| names.iter().any(|n| n == name))
2627 .unwrap_or(false),
2628 }
2629}
2630
2631pub fn scan_managed_services() -> Result<Vec<String>> {
2644 let dir = match quadlet_dir() {
2645 Ok(d) => d,
2646 Err(_) => return Ok(Vec::new()),
2647 };
2648 let entries = match std::fs::read_dir(&dir) {
2649 Ok(e) => e,
2650 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
2651 Err(source) => return Err(Error::FileRead { path: dir, source }),
2652 };
2653 let mut names: Vec<String> = Vec::new();
2654 for entry in entries.flatten() {
2655 let path = entry.path();
2656 if path.extension().and_then(|e| e.to_str()) != Some("container") {
2657 continue;
2658 }
2659 let Ok(content) = std::fs::read_to_string(&path) else {
2660 continue;
2661 };
2662 for line in content.lines().take(16) {
2663 if let Some(rest) = line.trim().strip_prefix("# Service-Source: registry/")
2664 && !rest.is_empty()
2665 && !names.iter().any(|n| n == rest)
2666 {
2667 names.push(rest.to_string());
2668 break;
2669 }
2670 }
2671 }
2672 names.sort();
2673 Ok(names)
2674}
2675
2676fn build_installed_from_metadata(service_name: &str) -> Option<InstalledService> {
2682 let meta = load_metadata(service_name).ok().flatten()?;
2683
2684 let exposure = match meta.url.as_deref() {
2686 None => Exposure::Loopback,
2687 Some(u) => Exposure::from_url(u),
2688 };
2689
2690 let auth_kind = meta.auth.clone();
2691
2692 let ports = service_home(service_name)
2698 .ok()
2699 .and_then(|home| std::fs::read_to_string(home.join(".env")).ok())
2700 .map(|env| {
2701 env.lines()
2702 .filter_map(|l| {
2703 let l = l.trim();
2704 if l.is_empty() || l.starts_with('#') {
2705 return None;
2706 }
2707 let (key, val) = l.split_once('=')?;
2708 let name = key.strip_prefix("SERVICE_PORT_")?.to_lowercase();
2709 let port = val
2710 .trim_matches(|c: char| c == '"' || c == '\'')
2711 .parse::<u16>()
2712 .ok()?;
2713 Some((name, port))
2714 })
2715 .collect::<std::collections::BTreeMap<String, u16>>()
2716 })
2717 .unwrap_or_default();
2718
2719 Some(InstalledService {
2720 name: service_name.to_string(),
2721 version: "0.1.0".to_string(),
2722 repo: meta.registry,
2723 ports,
2724 auth_kind,
2725 exposure,
2726 provides: meta.provides,
2727 installed: true,
2728 })
2729}
2730
2731pub fn list_installed() -> Result<Vec<InstalledService>> {
2738 let mut names: std::collections::BTreeSet<String> = scan_managed_services()
2739 .unwrap_or_default()
2740 .into_iter()
2741 .collect();
2742 if let Ok(root) = service_data_root()
2746 && let Ok(entries) = std::fs::read_dir(&root)
2747 {
2748 for entry in entries.flatten() {
2749 if let Some(name) = entry.file_name().to_str()
2750 && !names.contains(name)
2751 && is_service_installed(name)
2752 {
2753 names.insert(name.to_string());
2754 }
2755 }
2756 }
2757 let out: Vec<InstalledService> = names
2758 .iter()
2759 .filter_map(|n| build_installed_from_metadata(n))
2760 .collect();
2761 Ok(out)
2762}
2763
2764pub fn search_services(repo_dir: &Path, query: Option<&str>) -> Result<Vec<SearchResult>> {
2766 let available = registry::list_available(repo_dir)?;
2767
2768 let results = available
2769 .into_iter()
2770 .filter(|reg_svc| match query {
2771 None => true,
2772 Some(q) => {
2773 let q = q.to_lowercase();
2774 reg_svc.def.service.name.to_lowercase().contains(&q)
2775 || reg_svc.def.service.description.to_lowercase().contains(&q)
2776 }
2777 })
2778 .map(|reg_svc| {
2779 let name = ®_svc.def.service.name;
2780 let installed = is_service_installed(name);
2781 let mut supports = Vec::new();
2782 for kind in ®_svc.def.integrations.auth {
2783 supports.push(kind.to_string());
2784 }
2785 if reg_svc.def.integrations.smtp {
2786 supports.push("smtp".to_string());
2787 }
2788 let recommended_ram_mb = reg_svc
2789 .def
2790 .requirements
2791 .as_ref()
2792 .and_then(|r| r.ram.recommended);
2793 SearchResult {
2794 name: name.clone(),
2795 description: reg_svc.def.service.description,
2796 installed,
2797 supports,
2798 recommended_ram_mb,
2799 }
2800 })
2801 .collect();
2802
2803 Ok(results)
2804}
2805
2806pub struct SearchResult {
2807 pub name: String,
2808 pub description: String,
2809 pub installed: bool,
2810 pub supports: Vec<String>,
2812 pub recommended_ram_mb: Option<u64>,
2815}
2816
2817pub async fn service_tests(service_name: &str) -> Result<ServiceTestInfo> {
2819 let installed = build_installed_from_metadata(service_name)
2820 .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
2821
2822 let service_ref = service_ref_from_installed(&installed);
2823 let repo_dir = resolve_registry_dir(&service_ref).await?;
2824
2825 let test_toml_path = repo_dir.join(service_name).join("test.toml");
2826 let env_file = service_home(service_name)?.join(".env");
2827
2828 if !test_toml_path.exists() {
2829 return Ok(ServiceTestInfo {
2830 service_name: service_name.to_string(),
2831 registry_name: service_ref.registry_name().to_string(),
2832 tests: vec![],
2833 env_file,
2834 });
2835 }
2836
2837 let content = std::fs::read_to_string(&test_toml_path).map_err(|source| Error::FileRead {
2838 path: test_toml_path.clone(),
2839 source,
2840 })?;
2841
2842 #[derive(serde::Deserialize)]
2843 struct TestFile {
2844 #[serde(default)]
2845 tests: Vec<registry::test_def::TestDef>,
2846 }
2847
2848 let parsed: TestFile = toml::from_str(&content).map_err(|source| Error::TomlParse {
2849 path: test_toml_path,
2850 source,
2851 })?;
2852
2853 Ok(ServiceTestInfo {
2854 service_name: service_name.to_string(),
2855 registry_name: service_ref.registry_name().to_string(),
2856 tests: parsed.tests,
2857 env_file,
2858 })
2859}
2860
2861pub struct ServiceTestInfo {
2862 pub service_name: String,
2863 pub registry_name: String,
2864 pub tests: Vec<registry::test_def::TestDef>,
2865 pub env_file: PathBuf,
2866}
2867
2868pub fn service_info(repo_dir: &Path, service_name: &str) -> Result<ServiceDetail> {
2870 let reg_service = registry::find_service(repo_dir, service_name)?;
2871 let def = ®_service.def;
2872
2873 Ok(ServiceDetail {
2874 name: def.service.name.clone(),
2875 description: def.service.description.clone(),
2876 url: def.service.url.clone(),
2877 ports: def
2878 .ports
2879 .iter()
2880 .map(|p| (p.container_port, p.protocol.clone(), p.name.clone()))
2881 .collect(),
2882 env_vars: def
2883 .env
2884 .iter()
2885 .map(|e| (e.name.clone(), e.prompt.clone()))
2886 .collect(),
2887 })
2888}
2889
2890pub struct ServiceDetail {
2891 pub name: String,
2892 pub description: String,
2893 pub url: Option<String>,
2894 pub ports: Vec<(u16, registry::service_def::PortProtocol, String)>,
2895 pub env_vars: Vec<(String, Option<String>)>,
2896}
2897
2898#[cfg(test)]
2899mod tests {
2900 use super::*;
2901
2902 fn write_demo_registry(tmp: &std::path::Path, deploy_line: &str) {
2907 let svc_dir = tmp.join("demo");
2908 std::fs::create_dir_all(svc_dir.join("quadlets")).unwrap();
2909 std::fs::write(
2910 svc_dir.join("service.toml"),
2911 format!(
2912 "[service]\n\
2913 name = \"demo\"\n\
2914 description = \"demo\"\n\
2915 runtime = \"podman\"\n\
2916 {deploy_line}\n\
2917 \n\
2918 [[ports]]\n\
2919 name = \"http\"\n\
2920 container_port = 8080\n"
2921 ),
2922 )
2923 .unwrap();
2924 std::fs::write(
2925 svc_dir.join("quadlets").join("demo.container"),
2926 "[Container]\n\
2927 Image=docker.io/traefik/whoami:latest\n\
2928 ContainerName=demo\n\
2929 PublishPort=${SERVICE_PORT_HTTP}:8080\n\
2930 EnvironmentFile=%h/.local/share/services/demo/.env\n\
2931 \n\
2932 [Service]\n\
2933 EnvironmentFile=%h/.local/share/services/demo/.env\n\
2934 \n\
2935 [Install]\n\
2936 WantedBy=default.target\n",
2937 )
2938 .unwrap();
2939 }
2940
2941 fn write_native_registry(tmp: &std::path::Path) {
2944 let svc_dir = tmp.join("napp");
2945 std::fs::create_dir_all(&svc_dir).unwrap();
2946 std::fs::write(
2947 svc_dir.join("service.toml"),
2948 "[service]\n\
2949 name = \"napp\"\n\
2950 description = \"native demo\"\n\
2951 runtime = \"native\"\n\
2952 run = \"python -m app\"\n\
2953 build = \"pip install -r requirements.txt\"\n\
2954 deploy = \"blue-green\"\n\
2955 health_check = \"/healthz\"\n\
2956 \n\
2957 [[ports]]\n\
2958 name = \"http\"\n\
2959 container_port = 8080\n",
2960 )
2961 .unwrap();
2962 std::fs::write(svc_dir.join("app.py"), "print('hi')\n").unwrap();
2964 }
2965
2966 fn plan_demo(tmp: &std::path::Path) -> AddResult {
2967 plan_service(tmp, "demo")
2968 }
2969
2970 fn plan_service(tmp: &std::path::Path, name: &'static str) -> AddResult {
2971 plan_service_exposed(tmp, name, exposure::Exposure::Loopback)
2972 }
2973
2974 fn plan_service_exposed(
2975 tmp: &std::path::Path,
2976 name: &'static str,
2977 exposure: exposure::Exposure,
2978 ) -> AddResult {
2979 let empty_map = std::collections::BTreeMap::new();
2980 let empty_ports: std::collections::BTreeMap<String, u16> =
2981 std::collections::BTreeMap::new();
2982 let empty_set = std::collections::BTreeSet::new();
2983 let port_in_use = |_p: u16| false;
2984 add_service(AddServiceParams {
2985 service_name: name,
2986 exposure: &exposure,
2987 auth: AuthChoice::None,
2988 enable_smtp: false,
2989 enable_backup: false,
2990 env_overrides: &empty_map,
2991 enabled_groups: &empty_set,
2992 selected_choices: &empty_map,
2993 registry_name: "test",
2994 repo_dir: tmp,
2995 pre_built_ctx: None,
2996 port_in_use: &port_in_use,
2997 acme_mode: None,
2998 mode: PlanMode::Add,
2999 port_overrides: &empty_ports,
3000 existing_env_file: None,
3001 allow_unset_required: false,
3002 })
3003 .expect("plan add")
3004 }
3005
3006 #[test]
3010 fn blue_green_podman_add_emits_two_slots_and_starts_blue() {
3011 let tmp = tempfile::tempdir().unwrap();
3012 write_demo_registry(
3013 tmp.path(),
3014 "deploy = \"blue-green\"\nhealth_check = \"/healthz\"",
3015 );
3016 let result = plan_demo(tmp.path());
3017
3018 let written: Vec<String> = result
3021 .steps
3022 .iter()
3023 .filter_map(|s| match s {
3024 Step::WriteFile(f) => f
3025 .path
3026 .file_name()
3027 .and_then(|n| n.to_str())
3028 .map(String::from),
3029 _ => None,
3030 })
3031 .collect();
3032 assert!(
3033 written.iter().any(|n| n == "demo-blue.container"),
3034 "got {written:?}"
3035 );
3036 assert!(
3037 written.iter().any(|n| n == "demo-green.container"),
3038 "got {written:?}"
3039 );
3040 assert!(
3041 !written.iter().any(|n| n == "demo.container"),
3042 "bare slot leaked: {written:?}"
3043 );
3044
3045 let blue = result
3047 .steps
3048 .iter()
3049 .find_map(|s| match s {
3050 Step::WriteFile(f) if f.path.ends_with("demo-blue.container") => Some(&f.content),
3051 _ => None,
3052 })
3053 .unwrap();
3054 assert!(blue.contains("ContainerName=demo-blue"));
3055 assert!(blue.contains("${SERVICE_PORT_HTTP_BLUE}"));
3056
3057 let started: Vec<&str> = result
3059 .steps
3060 .iter()
3061 .filter_map(|s| match s {
3062 Step::StartService { unit } => Some(unit.as_str()),
3063 _ => None,
3064 })
3065 .collect();
3066 assert!(started.contains(&"demo-blue"), "started: {started:?}");
3067 assert!(!started.contains(&"demo"), "bare unit started: {started:?}");
3068
3069 let env = result
3071 .steps
3072 .iter()
3073 .find_map(|s| match s {
3074 Step::WriteFile(f) if f.path.file_name() == Some(std::ffi::OsStr::new(".env")) => {
3075 Some(&f.content)
3076 }
3077 _ => None,
3078 })
3079 .unwrap();
3080 assert!(env.contains("SERVICE_PORT_HTTP_BLUE="), "env: {env}");
3081 assert!(env.contains("SERVICE_PORT_HTTP_GREEN="), "env: {env}");
3082 }
3083
3084 #[test]
3088 fn blue_green_native_add_syncs_builds_and_starts_blue() {
3089 let tmp = tempfile::tempdir().unwrap();
3090 write_native_registry(tmp.path());
3091 let result = plan_service(tmp.path(), "napp");
3092
3093 let syncs: Vec<String> = result
3095 .steps
3096 .iter()
3097 .filter_map(|s| match s {
3098 Step::SyncDir { dst, .. } => Some(dst.to_string_lossy().into_owned()),
3099 _ => None,
3100 })
3101 .collect();
3102 assert!(
3103 syncs.iter().any(|d| d.ends_with("colors/blue")),
3104 "syncs: {syncs:?}"
3105 );
3106 assert!(
3107 syncs.iter().any(|d| d.ends_with("colors/green")),
3108 "syncs: {syncs:?}"
3109 );
3110 let builds: Vec<String> = result
3111 .steps
3112 .iter()
3113 .filter_map(|s| match s {
3114 Step::Build { dir, .. } => Some(dir.to_string_lossy().into_owned()),
3115 _ => None,
3116 })
3117 .collect();
3118 assert!(
3119 builds.iter().any(|d| d.ends_with("colors/blue")),
3120 "builds: {builds:?}"
3121 );
3122 assert!(
3123 builds.iter().any(|d| d.ends_with("colors/green")),
3124 "builds: {builds:?}"
3125 );
3126
3127 let green_unit = result
3130 .steps
3131 .iter()
3132 .find_map(|s| match s {
3133 Step::WriteFile(f) if f.path.ends_with("napp-green.service") => Some(&f.content),
3134 _ => None,
3135 })
3136 .expect("green unit");
3137 assert!(green_unit.contains("WorkingDirectory="));
3138 assert!(green_unit.contains("colors/green"));
3139 assert!(green_unit.contains("Environment=SERVICE_PORT_HTTP="));
3140 assert!(green_unit.contains("ExecStart=/bin/sh -c 'exec python -m app'"));
3141
3142 let started: Vec<&str> = result
3144 .steps
3145 .iter()
3146 .filter_map(|s| match s {
3147 Step::StartService { unit } => Some(unit.as_str()),
3148 _ => None,
3149 })
3150 .collect();
3151 assert_eq!(started, vec!["napp-blue"], "started: {started:?}");
3152
3153 let env = result
3155 .steps
3156 .iter()
3157 .find_map(|s| match s {
3158 Step::WriteFile(f) if f.path.file_name() == Some(std::ffi::OsStr::new(".env")) => {
3159 Some(&f.content)
3160 }
3161 _ => None,
3162 })
3163 .unwrap();
3164 assert!(env.contains("SERVICE_PORT_HTTP_BLUE="));
3165 assert!(env.contains("SERVICE_PORT_HTTP_GREEN="));
3166 }
3167
3168 #[test]
3174 fn blue_green_native_add_with_url_warns_when_no_caddy() {
3175 let tmp = tempfile::tempdir().unwrap();
3176 write_native_registry(tmp.path());
3177 let result = plan_service_exposed(
3178 tmp.path(),
3179 "napp",
3180 exposure::Exposure::Public {
3181 url: "https://napp.example.com".into(),
3182 },
3183 );
3184 assert!(
3185 result
3186 .warnings
3187 .iter()
3188 .any(|w| matches!(w, Warning::UrlWithoutReverseProxy { .. })),
3189 "native + url + no caddy should warn UrlWithoutReverseProxy"
3190 );
3191 }
3192
3193 #[test]
3196 fn restart_podman_add_is_unchanged() {
3197 let tmp = tempfile::tempdir().unwrap();
3198 write_demo_registry(tmp.path(), "");
3199 let result = plan_demo(tmp.path());
3200 let written: Vec<String> = result
3201 .steps
3202 .iter()
3203 .filter_map(|s| match s {
3204 Step::WriteFile(f) => f
3205 .path
3206 .file_name()
3207 .and_then(|n| n.to_str())
3208 .map(String::from),
3209 _ => None,
3210 })
3211 .collect();
3212 assert!(
3213 written.iter().any(|n| n == "demo.container"),
3214 "got {written:?}"
3215 );
3216 assert!(
3217 !written.iter().any(|n| n.contains("-blue")),
3218 "got {written:?}"
3219 );
3220 let started: Vec<&str> = result
3221 .steps
3222 .iter()
3223 .filter_map(|s| match s {
3224 Step::StartService { unit } => Some(unit.as_str()),
3225 _ => None,
3226 })
3227 .collect();
3228 assert!(started.contains(&"demo"));
3229 }
3230
3231 #[test]
3232 fn static_template_filter_excludes_secrets_and_credentials() {
3233 assert!(is_static_template("3306"));
3235 assert!(is_static_template("mariadb"));
3236 assert!(is_static_template("{{service.port}}"));
3238 assert!(is_static_template("{{service.url}}"));
3239 assert!(is_static_template("{{auth.url}}"));
3240 assert!(is_static_template("{{auth.issuer}}"));
3241 assert!(is_static_template("{{auth.provider}}"));
3242 assert!(is_static_template("{{auth.internal_url}}"));
3243 assert!(is_static_template("{{smtp.host}}"));
3244 assert!(is_static_template("{{smtp.port}}"));
3245 assert!(is_static_template("{{smtp.from}}"));
3246 assert!(is_static_template("{{service.url}}/oauth/callback"));
3248
3249 assert!(!is_static_template("{{secret.admin_password}}"));
3251 assert!(!is_static_template("{{secret.jwt_key}}"));
3252 assert!(!is_static_template("{{auth.client_id}}"));
3254 assert!(!is_static_template("{{auth.client_secret}}"));
3255 assert!(!is_static_template("{{smtp.username}}"));
3257 assert!(!is_static_template("{{smtp.password}}"));
3258 assert!(!is_static_template(
3260 "redis://:{{secret.redis_pw}}@host:6379"
3261 ));
3262 }
3263
3264 #[test]
3265 fn tailscale_url_matches() {
3266 assert!(is_tailscale_url("http://debian.cobbler-tuna.ts.net"));
3267 assert!(is_tailscale_url("http://debian.cobbler-tuna.ts.net:10001/"));
3268 assert!(is_tailscale_url("https://foo.example-net.ts.net"));
3269 assert!(is_tailscale_url("http://HOST.COBBLER-TUNA.TS.NET"));
3270 }
3271
3272 #[test]
3273 fn tailscale_url_rejects() {
3274 assert!(!is_tailscale_url("https://nextcloud.internal:8443"));
3275 assert!(!is_tailscale_url("https://example.com"));
3276 assert!(!is_tailscale_url("http://127.0.0.1:10001"));
3277 assert!(!is_tailscale_url("https://ts.net"));
3279 assert!(!is_tailscale_url("https://evil-ts.net.example.com"));
3280 assert!(!is_tailscale_url("not a url"));
3281 }
3282
3283 #[test]
3284 fn public_url_accepts_public_domains() {
3285 assert!(is_public_url("https://seafile.ryra.no"));
3286 assert!(is_public_url("https://example.com"));
3287 assert!(is_public_url("https://docs.ryra.no:8443"));
3288 }
3289
3290 #[test]
3291 fn public_url_rejects_lan_and_tailnet() {
3292 assert!(!is_public_url("https://nextcloud.internal:8443"));
3293 assert!(!is_public_url("https://service.localhost"));
3294 assert!(!is_public_url("https://something.local"));
3295 assert!(!is_public_url("https://localhost:8080"));
3296 assert!(!is_public_url("https://debian.cobbler-tuna.ts.net"));
3297 assert!(!is_public_url("http://127.0.0.1:10001"));
3298 assert!(!is_public_url("http://192.168.1.10"));
3299 assert!(!is_public_url("http://[::1]"));
3300 assert!(!is_public_url("not a url"));
3301 }
3302
3303 #[test]
3308 fn networks_empty_when_no_auth() {
3309 let nets = resolve_extra_networks(
3310 "whoami", false, false, false, false, false, false, None, false,
3311 );
3312 assert!(nets.is_empty());
3313 }
3314
3315 #[test]
3316 fn networks_empty_when_auth_but_no_authelia() {
3317 let nets = resolve_extra_networks(
3318 "forgejo", true, false, false, false, false, false, None, false,
3319 );
3320 assert!(nets.is_empty());
3321 }
3322
3323 #[test]
3324 fn networks_authelia_when_auth_enabled() {
3325 let nets = resolve_extra_networks(
3326 "forgejo", true, true, false, false, false, false, None, false,
3327 );
3328 assert_eq!(nets, vec!["authelia"]);
3329 }
3330
3331 #[test]
3332 fn networks_auth_with_caddy_includes_both() {
3333 let nets = resolve_extra_networks(
3334 "forgejo", true, true, true, false, false, false, None, false,
3335 );
3336 assert!(nets.contains(&"authelia".to_string()));
3337 assert!(nets.contains(&"caddy".to_string()));
3338 }
3339
3340 #[test]
3341 fn networks_authelia_excluded_for_authelia_itself() {
3342 let nets = resolve_extra_networks(
3343 "authelia", true, true, false, false, false, false, None, false,
3344 );
3345 assert!(nets.is_empty());
3346 }
3347
3348 #[test]
3349 fn networks_smtp_joins_inbucket_without_caddy() {
3350 let nets = resolve_extra_networks(
3352 "forgejo", false, false, false, true, false, true, None, false,
3353 );
3354 assert_eq!(nets, vec!["inbucket"]);
3355 }
3356
3357 #[test]
3358 fn networks_smtp_skips_inbucket_when_it_is_self() {
3359 let nets = resolve_extra_networks(
3360 "inbucket", false, false, false, true, false, true, None, false,
3361 );
3362 assert!(!nets.contains(&"inbucket".to_string()));
3363 }
3364
3365 #[test]
3366 fn networks_smtp_skips_inbucket_when_not_installed() {
3367 let nets = resolve_extra_networks(
3368 "forgejo", false, false, false, false, false, true, None, false,
3369 );
3370 assert!(!nets.contains(&"inbucket".to_string()));
3371 }
3372
3373 #[test]
3374 fn networks_metrics_consumer_joins_store() {
3375 let nets = resolve_extra_networks(
3376 "grafana",
3377 false,
3378 false,
3379 false,
3380 false,
3381 false,
3382 false,
3383 Some("prometheus"),
3384 true,
3385 );
3386 assert_eq!(nets, vec!["prometheus".to_string()]);
3387 }
3388
3389 #[test]
3390 fn networks_metrics_store_skips_itself() {
3391 let nets = resolve_extra_networks(
3392 "prometheus",
3393 false,
3394 false,
3395 false,
3396 false,
3397 false,
3398 false,
3399 Some("prometheus"),
3400 true,
3401 );
3402 assert!(nets.is_empty());
3403 }
3404
3405 #[test]
3406 fn networks_metrics_indifferent_service_skips_store() {
3407 let nets = resolve_extra_networks(
3408 "vaultwarden",
3409 false,
3410 false,
3411 false,
3412 false,
3413 false,
3414 false,
3415 Some("prometheus"),
3416 false,
3417 );
3418 assert!(nets.is_empty());
3419 }
3420
3421 #[test]
3422 fn quadlet_belongs_to_exact_match() {
3423 let all = &["foo", "foo-bar"];
3424 assert!(quadlet_belongs_to("foo.container", "foo", all));
3425 assert!(quadlet_belongs_to("foo.network", "foo", all));
3426 }
3427
3428 #[test]
3429 fn quadlet_belongs_to_sidecar() {
3430 let all = &["foo"];
3432 assert!(quadlet_belongs_to("foo-db.volume", "foo", all));
3433 }
3434
3435 #[test]
3436 fn quadlet_belongs_to_rejects_prefix_collision() {
3437 let all = &["foo", "foo-bar"];
3438 assert!(!quadlet_belongs_to("foo-bar.container", "foo", all));
3439 assert!(!quadlet_belongs_to("foo-bar-db.volume", "foo", all));
3440 }
3441
3442 #[test]
3443 fn quadlet_belongs_to_hyphenated_service() {
3444 let all = &["foo", "foo-bar"];
3445 assert!(quadlet_belongs_to("foo-bar.container", "foo-bar", all));
3446 assert!(quadlet_belongs_to("foo-bar-db.volume", "foo-bar", all));
3447 assert!(!quadlet_belongs_to("foo.container", "foo-bar", all));
3448 }
3449}