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 manifest_entries.push(manifest::ManifestEntry {
1312 path: file.path.clone(),
1313 sha256: manifest::hash_bytes(file.content.as_bytes()),
1314 });
1315 }
1316 }
1317 let tracked_envs = collect_static_envs(
1325 ®_service.def,
1326 &output.ctx,
1327 enabled_groups,
1328 selected_choices,
1329 )?;
1330 let manifest_envs: Vec<manifest::EnvEntry> = tracked_envs
1331 .iter()
1332 .map(|t| manifest::EnvEntry {
1333 key: t.key.clone(),
1334 value: t.value.clone(),
1335 })
1336 .collect();
1337 steps.push(Step::WriteFile(GeneratedFile {
1338 path: manifest_path_for_svc,
1339 content: manifest::format(&manifest_entries, &manifest_envs),
1340 }));
1341
1342 steps.push(Step::DaemonReload);
1344 let start_unit = if blue_green {
1348 deploy::color_unit(service_name, registry::service_def::Color::Blue)
1349 } else {
1350 service_name.to_string()
1351 };
1352 steps.push(Step::StartService { unit: start_unit });
1353
1354 let allocated_ports: Vec<(String, u16)> = resolved_ports.clone();
1356
1357 let mut generated_secrets: Vec<String> = reg_service
1359 .def
1360 .env
1361 .iter()
1362 .filter(|e| !env_overrides.contains_key(&e.name))
1363 .flat_map(|e| generate::extract_secret_refs(&e.value))
1364 .collect();
1365 generated_secrets.sort();
1367 generated_secrets.dedup();
1368
1369 Ok(AddResult {
1370 steps,
1371 warnings,
1372 repo_url: registry_name.to_string(),
1373 allocated_ports,
1374 generated_secrets,
1375 env_content,
1376 url: url.map(|u| u.to_string()),
1377 tracked_envs,
1378 })
1379}
1380
1381fn collect_generated_secrets(
1384 def: ®istry::service_def::ServiceDef,
1385 env_overrides: &BTreeMap<String, String>,
1386) -> Vec<String> {
1387 let mut out: Vec<String> = def
1388 .env
1389 .iter()
1390 .filter(|e| !env_overrides.contains_key(&e.name))
1391 .flat_map(|e| generate::extract_secret_refs(&e.value))
1392 .collect();
1393 out.sort();
1394 out.dedup();
1395 out
1396}
1397
1398struct NativeAddParams<'a> {
1400 service_name: &'a str,
1401 reg_service: &'a registry::RegistryService,
1402 home_dir: &'a Path,
1403 output: generate::EnvOutput,
1404 install_metadata: &'a Metadata,
1405 registry_name: &'a str,
1406 url: Option<&'a str>,
1407 tracked_envs: Vec<TrackedEnv>,
1408 allocated_ports: Vec<(String, u16)>,
1409 generated_secrets: Vec<String>,
1410 excluded_quadlets: Vec<String>,
1414 caddy_steps: Vec<Step>,
1418 warnings: Vec<Warning>,
1421}
1422
1423fn excluded_quadlets(
1427 def: ®istry::service_def::ServiceDef,
1428 selected_choices: &BTreeMap<String, String>,
1429) -> Vec<String> {
1430 let mut all_claimed: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
1431 let mut selected: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
1432 for choice in &def.choices {
1433 let picked = selected_choices
1434 .get(&choice.name)
1435 .unwrap_or(&choice.default);
1436 for option in &choice.options {
1437 for q in &option.quadlets {
1438 all_claimed.insert(q.clone());
1439 if &option.name == picked {
1440 selected.insert(q.clone());
1441 }
1442 }
1443 }
1444 }
1445 all_claimed.difference(&selected).cloned().collect()
1446}
1447
1448fn build_native_add(p: NativeAddParams<'_>) -> Result<AddResult> {
1454 let NativeAddParams {
1455 service_name,
1456 reg_service,
1457 home_dir,
1458 output,
1459 install_metadata,
1460 registry_name,
1461 url,
1462 tracked_envs,
1463 allocated_ports,
1464 generated_secrets,
1465 excluded_quadlets,
1466 caddy_steps,
1467 warnings,
1468 } = p;
1469
1470 let run = reg_service.def.service.run.as_ref().ok_or_else(|| {
1471 Error::Bundle(format!(
1472 "native service '{service_name}' is missing its `run` command"
1473 ))
1474 })?;
1475 let build = reg_service.def.service.build.as_ref();
1476
1477 let env_content = output.env_file.content.clone();
1478 let source_dir = reg_service.service_dir.clone();
1479 let mut steps = Vec::new();
1480
1481 steps.push(Step::CreateDir(home_dir.to_path_buf()));
1486 steps.push(Step::CreateDir(home_dir.join("data")));
1487
1488 let blue_green =
1489 reg_service.def.service.deploy == registry::service_def::DeployStrategy::BlueGreen;
1490
1491 steps.push(Step::WriteFile(GeneratedFile {
1494 path: metadata_path(service_name)?,
1495 content: toml::to_string_pretty(install_metadata)?,
1496 }));
1497 steps.push(Step::WriteFile(output.env_file));
1498
1499 let description = reg_service.def.service.description.as_str();
1500 if blue_green {
1501 let primary = allocated_ports
1507 .iter()
1508 .find(|(n, _)| n.eq_ignore_ascii_case("http"))
1509 .or_else(|| allocated_ports.first())
1510 .map(|(n, _)| n.clone())
1511 .ok_or_else(|| {
1512 Error::Bundle(format!(
1513 "blue/green native '{service_name}' has no port to route"
1514 ))
1515 })?;
1516 let port_var = format!("SERVICE_PORT_{}", primary.to_uppercase());
1517 let home_str = home_dir.to_string_lossy().into_owned();
1518 for color in [
1519 registry::service_def::Color::Blue,
1520 registry::service_def::Color::Green,
1521 ] {
1522 let slot = home_dir.join("colors").join(color.as_str());
1523 let slot_str = slot.to_string_lossy().into_owned();
1524 let port = allocated_ports
1525 .iter()
1526 .find(|(n, _)| *n == format!("{}_{}", primary.to_ascii_lowercase(), color))
1527 .map(|(_, p)| *p)
1528 .ok_or_else(|| {
1529 Error::Bundle(format!(
1530 "blue/green native '{service_name}' missing the {color} port"
1531 ))
1532 })?;
1533 steps.push(Step::SyncDir {
1535 src: source_dir.clone(),
1536 dst: slot.clone(),
1537 });
1538 if let Some(command) = build {
1539 steps.push(Step::Build {
1540 dir: slot.clone(),
1541 command: command.clone(),
1542 });
1543 }
1544 let unit_name = format!("{}.service", deploy::color_unit(service_name, color));
1545 let unit_path = home_dir.join(&unit_name);
1546 steps.push(Step::WriteFile(GeneratedFile {
1547 path: unit_path.clone(),
1548 content: deploy::native_color_unit(&deploy::NativeColorUnit {
1549 description,
1550 color,
1551 workdir: &slot_str,
1552 home: &home_str,
1553 port_var: &port_var,
1554 port,
1555 run,
1556 }),
1557 }));
1558 steps.push(Step::Symlink {
1559 link: systemd_user_dir()?.join(&unit_name),
1560 target: unit_path,
1561 });
1562 }
1563 } else {
1564 if let Some(command) = build {
1566 steps.push(Step::Build {
1567 dir: source_dir.clone(),
1568 command: command.clone(),
1569 });
1570 }
1571 let unit_name = format!("{service_name}.service");
1574 let unit_path = home_dir.join(&unit_name);
1575 steps.push(Step::WriteFile(GeneratedFile {
1576 path: unit_path.clone(),
1577 content: native_unit(home_dir, &source_dir, run, description),
1578 }));
1579 steps.push(Step::Symlink {
1580 link: systemd_user_dir()?.join(&unit_name),
1581 target: unit_path,
1582 });
1583 }
1584
1585 let mut quadlet_units: Vec<String> = Vec::new();
1592 if source_dir.join("quadlets").is_dir() {
1593 let port_names: Vec<String> = allocated_ports.iter().map(|(n, _)| n.clone()).collect();
1596 let bundle =
1597 generate::bundle::process_quadlet_bundle(&generate::bundle::ProcessBundleParams {
1598 service_dir: &source_dir,
1599 service_name,
1600 extra_networks: &[],
1601 extra_volumes: &[],
1602 podman_args: &[],
1603 extra_exec_start_pre: &[],
1604 port_names: &port_names,
1605 excluded_quadlets: &excluded_quadlets,
1606 })?;
1607 for image in &bundle.images {
1608 steps.push(Step::PullImage {
1609 image: image.clone(),
1610 });
1611 }
1612 for dir in &bundle.bind_mount_dirs {
1613 steps.push(Step::CreateDir(dir.clone()));
1614 }
1615 let quadlet_path = quadlet_dir()?;
1616 for file in bundle.quadlet_files {
1617 let fname = file
1618 .path
1619 .file_name()
1620 .ok_or_else(|| {
1621 Error::Bundle(format!("invalid quadlet path: {}", file.path.display()))
1622 })?
1623 .to_os_string();
1624 if let Some(stem) = fname.to_string_lossy().strip_suffix(".container") {
1625 quadlet_units.push(stem.to_string());
1626 }
1627 let link = quadlet_path.join(&fname);
1628 let target = file.path.clone();
1629 steps.push(Step::WriteFile(file));
1630 steps.push(Step::Symlink { link, target });
1631 }
1632 }
1633
1634 steps.push(Step::DaemonReload);
1635 for unit in &quadlet_units {
1637 steps.push(Step::StartService { unit: unit.clone() });
1638 }
1639 let app_unit = if blue_green {
1642 deploy::color_unit(service_name, registry::service_def::Color::Blue)
1643 } else {
1644 service_name.to_string()
1645 };
1646 steps.push(Step::EnableService {
1652 unit: app_unit.clone(),
1653 });
1654 steps.push(Step::StartService { unit: app_unit });
1655
1656 steps.extend(caddy_steps);
1659
1660 Ok(AddResult {
1661 steps,
1662 warnings,
1663 repo_url: registry_name.to_string(),
1664 allocated_ports,
1665 generated_secrets,
1666 env_content,
1667 url: url.map(|u| u.to_string()),
1668 tracked_envs,
1669 })
1670}
1671
1672fn native_unit(home_dir: &Path, source_dir: &Path, run: &str, description: &str) -> String {
1677 let home = home_dir.display();
1678 let source = source_dir.display();
1679 format!(
1688 "[Unit]\n\
1689 Description={description}\n\
1690 After=network.target\n\
1691 \n\
1692 [Service]\n\
1693 Type=simple\n\
1694 WorkingDirectory={source}\n\
1695 EnvironmentFile={home}/.env\n\
1696 Environment=SERVICE_HOME={home}\n\
1697 Environment=PATH=%h/.local/bin:%h/.cargo/bin:%h/.bun/bin:%h/.deno/bin:%h/go/bin:/usr/local/bin:/usr/bin:/bin\n\
1698 ExecStart=/bin/sh -c 'exec {run}'\n\
1699 Restart=always\n\
1700 RestartSec=5\n\
1701 \n\
1702 [Install]\n\
1703 WantedBy=default.target\n",
1704 )
1705}
1706
1707pub fn quadlet_belongs_to(filename: &str, service_name: &str, all_service_names: &[&str]) -> bool {
1716 if !filename.starts_with(service_name) {
1717 return false;
1718 }
1719 let rest = &filename[service_name.len()..];
1720 if rest.starts_with('.') {
1721 return true;
1722 }
1723 if !rest.starts_with('-') {
1724 return false;
1725 }
1726 !all_service_names.iter().any(|&other| {
1730 other.len() > service_name.len()
1731 && other.starts_with(service_name)
1732 && filename.starts_with(other)
1733 && filename[other.len()..].starts_with(['.', '-'])
1734 })
1735}
1736
1737#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
1739#[serde(rename_all = "snake_case")]
1740pub enum RemoveMode {
1741 #[default]
1742 Preserve,
1747 Purge,
1749}
1750
1751pub fn remove_service(service_name: &str, mode: RemoveMode) -> Result<RemoveResult> {
1753 let installed_owned = build_installed_from_metadata(service_name)
1756 .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
1757 let installed = &installed_owned;
1758
1759 if let Ok(Some(meta)) = metadata::load_metadata(service_name)
1764 && meta.runtime == registry::service_def::Runtime::Native
1765 {
1766 let url = installed.exposure.url().map(|s| s.to_string());
1767 return remove_native_service(service_name, mode, url);
1768 }
1769
1770 let quadlet_path = quadlet_dir()?;
1773 let mut steps = Vec::new();
1774 let mut volume_names = Vec::new();
1775 let mut networks: Vec<String> = Vec::new();
1776 let mut has_named_volumes = false;
1777 let name_pool = scan_managed_services().unwrap_or_default();
1781 let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
1782
1783 if let Some(svc_name) = installed.exposure.tailscale_svc_name() {
1795 steps.push(Step::TailscaleDisable { svc_name });
1796 }
1797
1798 if quadlet_path.is_dir()
1799 && let Ok(entries) = std::fs::read_dir(&quadlet_path)
1800 {
1801 for entry in entries.flatten() {
1802 let file_name = entry.file_name();
1803 let name = file_name.to_string_lossy();
1804 if !quadlet_belongs_to(&name, service_name, &all_names) {
1807 continue;
1808 }
1809 if name.ends_with(".container") {
1811 let unit = name.trim_end_matches(".container").to_string();
1812 steps.push(Step::StopService { unit });
1813 }
1814 if name.ends_with(".network") {
1815 let net = name.trim_end_matches(".network").to_string();
1818 steps.push(Step::StopService {
1819 unit: format!("{net}-network"),
1820 });
1821 networks.push(net);
1822 }
1823 if name.ends_with(".volume") {
1824 has_named_volumes = true;
1825 if matches!(mode, RemoveMode::Purge) {
1826 let vol = name.trim_end_matches(".volume").to_string();
1827 volume_names.push(format!("systemd-{vol}"));
1829 }
1830 }
1831 steps.push(Step::RemoveFile(entry.path()));
1832 }
1833 }
1834
1835 let had_caddy_route = matches!(
1842 installed.exposure,
1843 Exposure::Internal { .. } | Exposure::Public { .. }
1844 );
1845 if !WellKnownService::Caddy.matches(service_name) && had_caddy_route {
1846 let caddyfile_path = caddy::caddyfile_path()?;
1847 if caddyfile_path.exists() {
1848 let existing =
1849 std::fs::read_to_string(&caddyfile_path).map_err(|source| Error::FileRead {
1850 path: caddyfile_path.clone(),
1851 source,
1852 })?;
1853 let updated = caddy::remove_route(&existing, service_name);
1854 if updated != existing {
1855 steps.push(Step::WriteFile(GeneratedFile {
1856 path: caddyfile_path,
1857 content: updated.clone(),
1858 }));
1859 if !updated.trim().is_empty() {
1862 steps.push(Step::ReloadCaddy);
1863 }
1864 }
1865 }
1866 }
1867
1868 if !WellKnownService::Authelia.matches(service_name)
1869 && matches!(
1870 installed.auth_kind,
1871 Some(registry::service_def::AuthKind::Oidc)
1872 )
1873 {
1874 steps.extend(authelia::unregister_oidc_client(service_name)?);
1875 }
1876
1877 let installed_all = list_installed().unwrap_or_default();
1883 for store in installed_all
1884 .iter()
1885 .filter(|s| installed_provides(s, Capability::MetricsStore))
1886 {
1887 if store.name != service_name
1888 && let Ok(target) = metrics_bridge::target_file_path(&store.name, service_name)
1889 && target.exists()
1890 {
1891 steps.push(Step::RemoveFile(target));
1892 }
1893 }
1894 if installed.provides.contains(&Capability::MetricsStore) {
1895 for dash in installed_all
1896 .iter()
1897 .filter(|s| installed_provides(s, Capability::MetricsDashboard))
1898 {
1899 if dash.name == service_name {
1900 continue;
1901 }
1902 if let Ok(ds) = metrics_bridge::datasource_file_path(&dash.name, service_name)
1903 && ds.exists()
1904 {
1905 steps.push(Step::RemoveFile(ds));
1906 steps.push(Step::RestartService {
1907 unit: dash.name.clone(),
1908 });
1909 }
1910 }
1911 }
1912
1913 steps.push(Step::DaemonReload);
1915
1916 for net in networks {
1924 steps.push(Step::RemoveNetwork { name: net });
1925 }
1926
1927 match mode {
1928 RemoveMode::Purge => {
1929 for vol_name in volume_names {
1931 steps.push(Step::RemoveVolume { name: vol_name });
1932 }
1933 steps.push(Step::RemoveDir(service_home(service_name)?));
1935 }
1936 RemoveMode::Preserve => {
1937 let home = service_home(service_name)?;
1941 let (data, ephemeral) = crate::data::classify::classify_home_dir(&home)?;
1942 for path in ephemeral {
1943 match std::fs::metadata(&path) {
1944 Ok(m) if m.is_dir() => steps.push(Step::RemoveDir(path)),
1945 Ok(_) => steps.push(Step::RemoveFile(path)),
1946 Err(_) => steps.push(Step::RemoveFile(path)),
1950 }
1951 }
1952 if data.is_empty() && !has_named_volumes && home.exists() {
1960 steps.push(Step::RemoveDir(home));
1961 }
1962 }
1963 }
1964
1965 let url = installed.exposure.url().map(|s| s.to_string());
1966
1967 Ok(RemoveResult {
1968 steps,
1969 service_name: service_name.to_string(),
1970 url,
1971 })
1972}
1973
1974fn remove_native_service(
1979 service_name: &str,
1980 mode: RemoveMode,
1981 url: Option<String>,
1982) -> Result<RemoveResult> {
1983 let home = service_home(service_name)?;
1984 let unit_dir = systemd_user_dir()?;
1988 let unit_names: Vec<String> = [
1989 format!("{service_name}.service"),
1990 format!("{service_name}-blue.service"),
1991 format!("{service_name}-green.service"),
1992 ]
1993 .into_iter()
1994 .filter(|u| unit_dir.join(u).exists())
1995 .collect();
1996 let mut steps = Vec::new();
1997
1998 let mut aux_container_files: Vec<String> = Vec::new();
2004 if let Ok(qdir) = quadlet_dir() {
2005 let names = scan_managed_services().unwrap_or_default();
2006 let all: Vec<&str> = names.iter().map(|s| s.as_str()).collect();
2007 if let Ok(entries) = std::fs::read_dir(&qdir) {
2008 for entry in entries.flatten() {
2009 let fname = entry.file_name().to_string_lossy().into_owned();
2010 if let Some(stem) = fname.strip_suffix(".container")
2011 && quadlet_belongs_to(&fname, service_name, &all)
2012 {
2013 steps.push(Step::StopService {
2014 unit: stem.to_string(),
2015 });
2016 steps.push(Step::RemoveFile(qdir.join(&fname)));
2017 aux_container_files.push(fname);
2018 }
2019 }
2020 }
2021 }
2022
2023 for unit_name in &unit_names {
2024 let bare = unit_name.trim_end_matches(".service").to_string();
2025 steps.push(Step::DisableService { unit: bare.clone() });
2028 steps.push(Step::StopService { unit: bare });
2029 steps.push(Step::RemoveFile(unit_dir.join(unit_name)));
2030 }
2031 steps.push(Step::DaemonReload);
2032
2033 match mode {
2034 RemoveMode::Purge => steps.push(Step::RemoveDir(home)),
2035 RemoveMode::Preserve => {
2036 let mut ephemeral: Vec<String> = vec!["bin".into(), ".env".into(), "colors".into()];
2041 ephemeral.extend(unit_names.iter().cloned());
2042 for child in &ephemeral {
2043 let p = home.join(child);
2044 match std::fs::metadata(&p) {
2045 Ok(m) if m.is_dir() => steps.push(Step::RemoveDir(p)),
2046 Ok(_) => steps.push(Step::RemoveFile(p)),
2047 Err(_) => {} }
2049 }
2050 for f in &aux_container_files {
2051 steps.push(Step::RemoveFile(home.join(f)));
2052 }
2053 }
2054 }
2055
2056 Ok(RemoveResult {
2057 steps,
2058 service_name: service_name.to_string(),
2059 url,
2060 })
2061}
2062
2063#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2065#[serde(rename_all = "snake_case")]
2066pub enum Lifecycle {
2067 Start,
2068 Stop,
2069}
2070
2071pub fn lifecycle_steps(service_name: &str, action: Lifecycle) -> Result<Vec<Step>> {
2080 build_installed_from_metadata(service_name)
2082 .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
2083
2084 if matches!(
2087 metadata::load_metadata(service_name),
2088 Ok(Some(m)) if m.runtime == registry::service_def::Runtime::Native
2089 ) {
2090 let unit = service_name.to_string();
2091 return Ok(vec![match action {
2092 Lifecycle::Start => Step::StartService { unit },
2093 Lifecycle::Stop => Step::StopService { unit },
2094 }]);
2095 }
2096
2097 let mut units = service_container_units(service_name)?;
2098 match action {
2099 Lifecycle::Stop => units.sort_by_key(|u| u != service_name),
2101 Lifecycle::Start => units.sort_by_key(|u| u == service_name),
2103 }
2104
2105 Ok(units
2106 .into_iter()
2107 .map(|unit| match action {
2108 Lifecycle::Start => Step::StartService { unit },
2109 Lifecycle::Stop => Step::StopService { unit },
2110 })
2111 .collect())
2112}
2113
2114fn service_container_units(service_name: &str) -> Result<Vec<String>> {
2118 let quadlet_path = quadlet_dir()?;
2119 let name_pool = scan_managed_services().unwrap_or_default();
2120 let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
2121
2122 let mut units = Vec::new();
2123 if quadlet_path.is_dir()
2124 && let Ok(entries) = std::fs::read_dir(&quadlet_path)
2125 {
2126 for entry in entries.flatten() {
2127 let file_name = entry.file_name();
2128 let name = file_name.to_string_lossy();
2129 if !quadlet_belongs_to(&name, service_name, &all_names) {
2130 continue;
2131 }
2132 if name.ends_with(".container") {
2133 units.push(name.trim_end_matches(".container").to_string());
2134 }
2135 }
2136 }
2137 Ok(units)
2138}
2139
2140pub struct RecordPendingParams<'a> {
2142 pub service_name: &'a str,
2143 pub auth_kind: Option<registry::service_def::AuthKind>,
2144 pub registry_name: &'a str,
2145 pub allocated_ports: &'a [(String, u16)],
2146 pub repo_dir: &'a Path,
2147 pub exposure: &'a Exposure,
2154}
2155
2156pub fn record_pending(params: RecordPendingParams<'_>) -> Result<()> {
2163 let paths = ConfigPaths::resolve()?;
2164 paths.ensure_dirs()?;
2165 let mut config = config::load_or_default(&paths.config_file)?;
2166
2167 if WellKnownService::Authelia.matches(params.service_name) {
2172 config.auth = Some(authelia::auth_config(
2173 params.allocated_ports,
2174 params.exposure.url(),
2175 )?);
2176 config::save_config(&paths.config_file, &config)?;
2177 }
2178
2179 Ok(())
2180}
2181
2182pub fn finalize_remove(service_name: &str) -> Result<()> {
2189 let paths = ConfigPaths::resolve()?;
2190 let mut config = config::load_or_default(&paths.config_file)?;
2191
2192 if WellKnownService::Authelia.matches(service_name)
2193 && let Some(auth) = &config.auth
2194 && auth.provider_name() == "authelia"
2195 {
2196 config.auth = None;
2197 config::save_config(&paths.config_file, &config)?;
2198 }
2199
2200 Ok(())
2201}
2202
2203const SENSITIVE_TEMPLATE_REFS: &[&str] = &[
2222 "{{secret.",
2223 "{{auth.client_id",
2224 "{{auth.client_secret",
2225 "{{smtp.username",
2226 "{{smtp.password",
2227];
2228
2229fn is_static_template(value: &str) -> bool {
2230 !SENSITIVE_TEMPLATE_REFS.iter().any(|s| value.contains(s))
2231}
2232
2233fn collect_static_envs(
2249 service_def: ®istry::service_def::ServiceDef,
2250 ctx: &BTreeMap<String, String>,
2251 enabled_groups: &std::collections::BTreeSet<String>,
2252 selected_choices: &BTreeMap<String, String>,
2253) -> Result<Vec<plan::TrackedEnv>> {
2254 use registry::service_def::EnvKind;
2255 let mut out: Vec<plan::TrackedEnv> = Vec::new();
2256 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
2257 let push = |name: &str,
2258 value_template: &str,
2259 kind: EnvKind,
2260 prompt: Option<String>,
2261 out: &mut Vec<plan::TrackedEnv>,
2262 seen: &mut std::collections::HashSet<String>|
2263 -> Result<()> {
2264 if !is_static_template(value_template) {
2265 return Ok(());
2266 }
2267 if !seen.insert(name.to_string()) {
2268 return Ok(());
2269 }
2270 let value = generate::template::render(value_template, ctx)?;
2271 out.push(plan::TrackedEnv {
2272 key: name.to_string(),
2273 value,
2274 kind,
2275 prompt,
2276 });
2277 Ok(())
2278 };
2279 for env in &service_def.env {
2280 push(
2281 &env.name,
2282 &env.value,
2283 env.kind.clone(),
2284 env.prompt.clone(),
2285 &mut out,
2286 &mut seen,
2287 )?;
2288 }
2289 for group in &service_def.env_groups {
2290 if !enabled_groups.contains(&group.name) {
2291 continue;
2292 }
2293 for env in &group.env {
2294 push(
2295 &env.name,
2296 &env.value,
2297 env.kind.clone(),
2298 env.prompt.clone(),
2299 &mut out,
2300 &mut seen,
2301 )?;
2302 }
2303 }
2304 for choice in &service_def.choices {
2307 let selected = selected_choices
2308 .get(&choice.name)
2309 .unwrap_or(&choice.default);
2310 let Some(option) = choice.options.iter().find(|o| &o.name == selected) else {
2311 continue;
2312 };
2313 for env in &option.env {
2314 push(
2315 &env.name,
2316 &env.value,
2317 env.kind.clone(),
2318 env.prompt.clone(),
2319 &mut out,
2320 &mut seen,
2321 )?;
2322 }
2323 }
2324 if service_def.integrations.smtp && ctx.contains_key("smtp.host") {
2330 for (env_name, value_template) in &service_def.mappings.smtp {
2331 push(
2332 env_name,
2333 value_template,
2334 EnvKind::Default,
2335 None,
2336 &mut out,
2337 &mut seen,
2338 )?;
2339 }
2340 }
2341 if ctx.contains_key("auth.client_id") {
2342 for (env_name, value_template) in &service_def.mappings.auth {
2343 push(
2344 env_name,
2345 value_template,
2346 EnvKind::Default,
2347 None,
2348 &mut out,
2349 &mut seen,
2350 )?;
2351 }
2352 }
2353 Ok(out)
2354}
2355
2356pub fn orphan_purge_steps(svc: &data::ServiceData) -> Vec<Step> {
2357 let mut steps = Vec::new();
2358
2359 let mut had_quadlet = false;
2365 let mut networks: Vec<String> = Vec::new();
2366 if let Ok(qdir) = quadlet_dir()
2367 && qdir.is_dir()
2368 && let Ok(entries) = std::fs::read_dir(&qdir)
2369 {
2370 let name_pool = scan_managed_services().unwrap_or_default();
2371 let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
2372 for entry in entries.flatten() {
2373 let file_name = entry.file_name();
2374 let name = file_name.to_string_lossy();
2375 if !quadlet_belongs_to(&name, &svc.service, &all_names) {
2376 continue;
2377 }
2378 if name.ends_with(".container") {
2382 let unit = name.trim_end_matches(".container").to_string();
2383 steps.push(Step::StopService { unit });
2384 } else if name.ends_with(".network") {
2385 let net = name.trim_end_matches(".network").to_string();
2386 steps.push(Step::StopService {
2387 unit: format!("{net}-network"),
2388 });
2389 networks.push(net);
2390 } else if name.ends_with(".volume") {
2391 let unit = format!("{}-volume", name.trim_end_matches(".volume"));
2392 steps.push(Step::StopService { unit });
2393 }
2394 steps.push(Step::RemoveFile(entry.path()));
2395 had_quadlet = true;
2396 }
2397 }
2398 if had_quadlet {
2399 steps.push(Step::DaemonReload);
2400 }
2401 for net in networks {
2403 steps.push(Step::RemoveNetwork { name: net });
2404 }
2405
2406 for path in &svc.data_paths {
2407 if path.is_dir() {
2408 steps.push(Step::RemoveDir(path.clone()));
2409 } else {
2410 steps.push(Step::RemoveFile(path.clone()));
2411 }
2412 }
2413 if svc.home_dir.exists() {
2414 steps.push(Step::RemoveDir(svc.home_dir.clone()));
2415 }
2416 for v in &svc.volumes {
2417 steps.push(Step::RemoveVolume {
2418 name: v.name.clone(),
2419 });
2420 }
2421 steps
2422}
2423
2424pub fn reset() -> Result<ResetResult> {
2426 let mut steps = Vec::new();
2427
2428 let managed_names = scan_managed_services().unwrap_or_default();
2433
2434 for svc in list_installed().unwrap_or_default() {
2441 if let Some(svc_name) = svc.exposure.tailscale_svc_name() {
2442 steps.push(Step::TailscaleDisable { svc_name });
2443 }
2444 }
2445
2446 let quadlet_path = quadlet_dir()?;
2448 let all_names: Vec<&str> = managed_names.iter().map(|s| s.as_str()).collect();
2449 let mut networks: Vec<String> = Vec::new();
2450 if quadlet_path.is_dir()
2451 && let Ok(entries) = std::fs::read_dir(&quadlet_path)
2452 {
2453 for entry in entries.flatten() {
2454 let file_name = entry.file_name();
2455 let name = file_name.to_string_lossy();
2456 let is_ryra_file = managed_names
2460 .iter()
2461 .any(|svc| quadlet_belongs_to(&name, svc, &all_names));
2462 if !is_ryra_file {
2463 continue;
2464 }
2465 if name.ends_with(".container") {
2466 let unit = name.trim_end_matches(".container").to_string();
2467 steps.push(Step::StopService { unit });
2468 }
2469 if name.ends_with(".network") {
2470 let net = name.trim_end_matches(".network").to_string();
2471 steps.push(Step::StopService {
2472 unit: format!("{net}-network"),
2473 });
2474 networks.push(net);
2475 }
2476 if name.ends_with(".volume") {
2477 let vol = name.trim_end_matches(".volume").to_string();
2478 steps.push(Step::StopService {
2485 unit: format!("{vol}-volume"),
2486 });
2487 }
2488 steps.push(Step::RemoveFile(entry.path()));
2489 }
2490 }
2491
2492 let user_unit_dir = systemd_user_dir()?;
2498 if let Ok(root) = service_data_root()
2499 && let Ok(entries) = std::fs::read_dir(&root)
2500 {
2501 for entry in entries.flatten() {
2502 let Some(name) = entry.file_name().to_str().map(str::to_string) else {
2503 continue;
2504 };
2505 if matches!(
2506 metadata::load_metadata(&name),
2507 Ok(Some(m)) if m.runtime == registry::service_def::Runtime::Native
2508 ) {
2509 steps.push(Step::StopService { unit: name.clone() });
2510 steps.push(Step::RemoveFile(
2511 user_unit_dir.join(format!("{name}.service")),
2512 ));
2513 }
2514 }
2515 }
2516
2517 steps.push(Step::DaemonReload);
2519
2520 for net in networks {
2523 steps.push(Step::RemoveNetwork { name: net });
2524 }
2525
2526 let mut seen_volumes = std::collections::BTreeSet::new();
2532 for svc in data::enumerate_all().unwrap_or_default() {
2533 for vol in svc.volumes {
2534 if seen_volumes.insert(vol.name.clone()) {
2535 steps.push(Step::RemoveVolume { name: vol.name });
2536 }
2537 }
2538 }
2539
2540 let data_root = service_data_root()?;
2546 if data_root.exists() {
2547 steps.push(Step::RemoveDir(data_root));
2548 }
2549
2550 Ok(ResetResult { steps })
2551}
2552
2553pub fn finalize_reset() -> Result<()> {
2555 let paths = ConfigPaths::resolve()?;
2556 if paths.config_dir.exists() {
2557 std::fs::remove_dir_all(&paths.config_dir).map_err(|source| Error::FileWrite {
2558 path: paths.config_dir,
2559 source,
2560 })?;
2561 }
2562 Ok(())
2563}
2564
2565pub fn status() -> config::status::RyraStatus {
2571 let paths = match ConfigPaths::resolve() {
2572 Ok(p) => p,
2573 Err(_) => return config::status::RyraStatus::NotInitialized,
2574 };
2575
2576 let has_quadlets = scan_managed_services()
2577 .map(|n| !n.is_empty())
2578 .unwrap_or(false);
2579
2580 let config = match config::load_config(&paths.config_file) {
2581 Ok(c) => c,
2582 Err(Error::ConfigNotFound(_)) if has_quadlets => config::schema::Config::default(),
2583 Err(Error::ConfigNotFound(_)) => return config::status::RyraStatus::NotInitialized,
2584 Err(e) => return config::status::RyraStatus::Error(e.to_string()),
2585 };
2586
2587 config::status::RyraStatus::Initialized(config::status::StatusInfo::from_config(
2588 paths.config_file,
2589 &config,
2590 ))
2591}
2592
2593pub fn is_service_installed(name: &str) -> bool {
2601 let Ok(Some(meta)) = metadata::load_metadata(name) else {
2605 return false;
2606 };
2607 match meta.runtime {
2608 registry::service_def::Runtime::Native => systemd_user_dir()
2611 .map(|d| {
2612 d.join(format!("{name}.service")).exists()
2613 || d.join(format!("{name}-blue.service")).exists()
2614 || d.join(format!("{name}-green.service")).exists()
2615 })
2616 .unwrap_or(false),
2617 registry::service_def::Runtime::Podman => scan_managed_services()
2618 .map(|names| names.iter().any(|n| n == name))
2619 .unwrap_or(false),
2620 }
2621}
2622
2623pub fn scan_managed_services() -> Result<Vec<String>> {
2636 let dir = match quadlet_dir() {
2637 Ok(d) => d,
2638 Err(_) => return Ok(Vec::new()),
2639 };
2640 let entries = match std::fs::read_dir(&dir) {
2641 Ok(e) => e,
2642 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
2643 Err(source) => return Err(Error::FileRead { path: dir, source }),
2644 };
2645 let mut names: Vec<String> = Vec::new();
2646 for entry in entries.flatten() {
2647 let path = entry.path();
2648 if path.extension().and_then(|e| e.to_str()) != Some("container") {
2649 continue;
2650 }
2651 let Ok(content) = std::fs::read_to_string(&path) else {
2652 continue;
2653 };
2654 for line in content.lines().take(16) {
2655 if let Some(rest) = line.trim().strip_prefix("# Service-Source: registry/")
2656 && !rest.is_empty()
2657 && !names.iter().any(|n| n == rest)
2658 {
2659 names.push(rest.to_string());
2660 break;
2661 }
2662 }
2663 }
2664 names.sort();
2665 Ok(names)
2666}
2667
2668fn build_installed_from_metadata(service_name: &str) -> Option<InstalledService> {
2674 let meta = load_metadata(service_name).ok().flatten()?;
2675
2676 let exposure = match meta.url.as_deref() {
2678 None => Exposure::Loopback,
2679 Some(u) => Exposure::from_url(u),
2680 };
2681
2682 let auth_kind = meta.auth.clone();
2683
2684 let ports = service_home(service_name)
2690 .ok()
2691 .and_then(|home| std::fs::read_to_string(home.join(".env")).ok())
2692 .map(|env| {
2693 env.lines()
2694 .filter_map(|l| {
2695 let l = l.trim();
2696 if l.is_empty() || l.starts_with('#') {
2697 return None;
2698 }
2699 let (key, val) = l.split_once('=')?;
2700 let name = key.strip_prefix("SERVICE_PORT_")?.to_lowercase();
2701 let port = val
2702 .trim_matches(|c: char| c == '"' || c == '\'')
2703 .parse::<u16>()
2704 .ok()?;
2705 Some((name, port))
2706 })
2707 .collect::<std::collections::BTreeMap<String, u16>>()
2708 })
2709 .unwrap_or_default();
2710
2711 Some(InstalledService {
2712 name: service_name.to_string(),
2713 version: "0.1.0".to_string(),
2714 repo: meta.registry,
2715 ports,
2716 auth_kind,
2717 exposure,
2718 provides: meta.provides,
2719 installed: true,
2720 })
2721}
2722
2723pub fn list_installed() -> Result<Vec<InstalledService>> {
2730 let mut names: std::collections::BTreeSet<String> = scan_managed_services()
2731 .unwrap_or_default()
2732 .into_iter()
2733 .collect();
2734 if let Ok(root) = service_data_root()
2738 && let Ok(entries) = std::fs::read_dir(&root)
2739 {
2740 for entry in entries.flatten() {
2741 if let Some(name) = entry.file_name().to_str()
2742 && !names.contains(name)
2743 && is_service_installed(name)
2744 {
2745 names.insert(name.to_string());
2746 }
2747 }
2748 }
2749 let out: Vec<InstalledService> = names
2750 .iter()
2751 .filter_map(|n| build_installed_from_metadata(n))
2752 .collect();
2753 Ok(out)
2754}
2755
2756pub fn search_services(repo_dir: &Path, query: Option<&str>) -> Result<Vec<SearchResult>> {
2758 let available = registry::list_available(repo_dir)?;
2759
2760 let results = available
2761 .into_iter()
2762 .filter(|reg_svc| match query {
2763 None => true,
2764 Some(q) => {
2765 let q = q.to_lowercase();
2766 reg_svc.def.service.name.to_lowercase().contains(&q)
2767 || reg_svc.def.service.description.to_lowercase().contains(&q)
2768 }
2769 })
2770 .map(|reg_svc| {
2771 let name = ®_svc.def.service.name;
2772 let installed = is_service_installed(name);
2773 let mut supports = Vec::new();
2774 for kind in ®_svc.def.integrations.auth {
2775 supports.push(kind.to_string());
2776 }
2777 if reg_svc.def.integrations.smtp {
2778 supports.push("smtp".to_string());
2779 }
2780 let recommended_ram_mb = reg_svc
2781 .def
2782 .requirements
2783 .as_ref()
2784 .and_then(|r| r.ram.recommended);
2785 SearchResult {
2786 name: name.clone(),
2787 description: reg_svc.def.service.description,
2788 installed,
2789 supports,
2790 recommended_ram_mb,
2791 }
2792 })
2793 .collect();
2794
2795 Ok(results)
2796}
2797
2798pub struct SearchResult {
2799 pub name: String,
2800 pub description: String,
2801 pub installed: bool,
2802 pub supports: Vec<String>,
2804 pub recommended_ram_mb: Option<u64>,
2807}
2808
2809pub async fn service_tests(service_name: &str) -> Result<ServiceTestInfo> {
2811 let installed = build_installed_from_metadata(service_name)
2812 .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
2813
2814 let service_ref = service_ref_from_installed(&installed);
2815 let repo_dir = resolve_registry_dir(&service_ref).await?;
2816
2817 let test_toml_path = repo_dir.join(service_name).join("test.toml");
2818 let env_file = service_home(service_name)?.join(".env");
2819
2820 if !test_toml_path.exists() {
2821 return Ok(ServiceTestInfo {
2822 service_name: service_name.to_string(),
2823 registry_name: service_ref.registry_name().to_string(),
2824 tests: vec![],
2825 env_file,
2826 });
2827 }
2828
2829 let content = std::fs::read_to_string(&test_toml_path).map_err(|source| Error::FileRead {
2830 path: test_toml_path.clone(),
2831 source,
2832 })?;
2833
2834 #[derive(serde::Deserialize)]
2835 struct TestFile {
2836 #[serde(default)]
2837 tests: Vec<registry::test_def::TestDef>,
2838 }
2839
2840 let parsed: TestFile = toml::from_str(&content).map_err(|source| Error::TomlParse {
2841 path: test_toml_path,
2842 source,
2843 })?;
2844
2845 Ok(ServiceTestInfo {
2846 service_name: service_name.to_string(),
2847 registry_name: service_ref.registry_name().to_string(),
2848 tests: parsed.tests,
2849 env_file,
2850 })
2851}
2852
2853pub struct ServiceTestInfo {
2854 pub service_name: String,
2855 pub registry_name: String,
2856 pub tests: Vec<registry::test_def::TestDef>,
2857 pub env_file: PathBuf,
2858}
2859
2860pub fn service_info(repo_dir: &Path, service_name: &str) -> Result<ServiceDetail> {
2862 let reg_service = registry::find_service(repo_dir, service_name)?;
2863 let def = ®_service.def;
2864
2865 Ok(ServiceDetail {
2866 name: def.service.name.clone(),
2867 description: def.service.description.clone(),
2868 url: def.service.url.clone(),
2869 ports: def
2870 .ports
2871 .iter()
2872 .map(|p| (p.container_port, p.protocol.clone(), p.name.clone()))
2873 .collect(),
2874 env_vars: def
2875 .env
2876 .iter()
2877 .map(|e| (e.name.clone(), e.prompt.clone()))
2878 .collect(),
2879 })
2880}
2881
2882pub struct ServiceDetail {
2883 pub name: String,
2884 pub description: String,
2885 pub url: Option<String>,
2886 pub ports: Vec<(u16, registry::service_def::PortProtocol, String)>,
2887 pub env_vars: Vec<(String, Option<String>)>,
2888}
2889
2890#[cfg(test)]
2891mod tests {
2892 use super::*;
2893
2894 fn write_demo_registry(tmp: &std::path::Path, deploy_line: &str) {
2899 let svc_dir = tmp.join("demo");
2900 std::fs::create_dir_all(svc_dir.join("quadlets")).unwrap();
2901 std::fs::write(
2902 svc_dir.join("service.toml"),
2903 format!(
2904 "[service]\n\
2905 name = \"demo\"\n\
2906 description = \"demo\"\n\
2907 runtime = \"podman\"\n\
2908 {deploy_line}\n\
2909 \n\
2910 [[ports]]\n\
2911 name = \"http\"\n\
2912 container_port = 8080\n"
2913 ),
2914 )
2915 .unwrap();
2916 std::fs::write(
2917 svc_dir.join("quadlets").join("demo.container"),
2918 "[Container]\n\
2919 Image=docker.io/traefik/whoami:latest\n\
2920 ContainerName=demo\n\
2921 PublishPort=${SERVICE_PORT_HTTP}:8080\n\
2922 EnvironmentFile=%h/.local/share/services/demo/.env\n\
2923 \n\
2924 [Service]\n\
2925 EnvironmentFile=%h/.local/share/services/demo/.env\n\
2926 \n\
2927 [Install]\n\
2928 WantedBy=default.target\n",
2929 )
2930 .unwrap();
2931 }
2932
2933 fn write_native_registry(tmp: &std::path::Path) {
2936 let svc_dir = tmp.join("napp");
2937 std::fs::create_dir_all(&svc_dir).unwrap();
2938 std::fs::write(
2939 svc_dir.join("service.toml"),
2940 "[service]\n\
2941 name = \"napp\"\n\
2942 description = \"native demo\"\n\
2943 runtime = \"native\"\n\
2944 run = \"python -m app\"\n\
2945 build = \"pip install -r requirements.txt\"\n\
2946 deploy = \"blue-green\"\n\
2947 health_check = \"/healthz\"\n\
2948 \n\
2949 [[ports]]\n\
2950 name = \"http\"\n\
2951 container_port = 8080\n",
2952 )
2953 .unwrap();
2954 std::fs::write(svc_dir.join("app.py"), "print('hi')\n").unwrap();
2956 }
2957
2958 fn plan_demo(tmp: &std::path::Path) -> AddResult {
2959 plan_service(tmp, "demo")
2960 }
2961
2962 fn plan_service(tmp: &std::path::Path, name: &'static str) -> AddResult {
2963 plan_service_exposed(tmp, name, exposure::Exposure::Loopback)
2964 }
2965
2966 fn plan_service_exposed(
2967 tmp: &std::path::Path,
2968 name: &'static str,
2969 exposure: exposure::Exposure,
2970 ) -> AddResult {
2971 let empty_map = std::collections::BTreeMap::new();
2972 let empty_ports: std::collections::BTreeMap<String, u16> =
2973 std::collections::BTreeMap::new();
2974 let empty_set = std::collections::BTreeSet::new();
2975 let port_in_use = |_p: u16| false;
2976 add_service(AddServiceParams {
2977 service_name: name,
2978 exposure: &exposure,
2979 auth: AuthChoice::None,
2980 enable_smtp: false,
2981 enable_backup: false,
2982 env_overrides: &empty_map,
2983 enabled_groups: &empty_set,
2984 selected_choices: &empty_map,
2985 registry_name: "test",
2986 repo_dir: tmp,
2987 pre_built_ctx: None,
2988 port_in_use: &port_in_use,
2989 acme_mode: None,
2990 mode: PlanMode::Add,
2991 port_overrides: &empty_ports,
2992 existing_env_file: None,
2993 allow_unset_required: false,
2994 })
2995 .expect("plan add")
2996 }
2997
2998 #[test]
3002 fn blue_green_podman_add_emits_two_slots_and_starts_blue() {
3003 let tmp = tempfile::tempdir().unwrap();
3004 write_demo_registry(
3005 tmp.path(),
3006 "deploy = \"blue-green\"\nhealth_check = \"/healthz\"",
3007 );
3008 let result = plan_demo(tmp.path());
3009
3010 let written: Vec<String> = result
3013 .steps
3014 .iter()
3015 .filter_map(|s| match s {
3016 Step::WriteFile(f) => f
3017 .path
3018 .file_name()
3019 .and_then(|n| n.to_str())
3020 .map(String::from),
3021 _ => None,
3022 })
3023 .collect();
3024 assert!(
3025 written.iter().any(|n| n == "demo-blue.container"),
3026 "got {written:?}"
3027 );
3028 assert!(
3029 written.iter().any(|n| n == "demo-green.container"),
3030 "got {written:?}"
3031 );
3032 assert!(
3033 !written.iter().any(|n| n == "demo.container"),
3034 "bare slot leaked: {written:?}"
3035 );
3036
3037 let blue = result
3039 .steps
3040 .iter()
3041 .find_map(|s| match s {
3042 Step::WriteFile(f) if f.path.ends_with("demo-blue.container") => Some(&f.content),
3043 _ => None,
3044 })
3045 .unwrap();
3046 assert!(blue.contains("ContainerName=demo-blue"));
3047 assert!(blue.contains("${SERVICE_PORT_HTTP_BLUE}"));
3048
3049 let started: Vec<&str> = result
3051 .steps
3052 .iter()
3053 .filter_map(|s| match s {
3054 Step::StartService { unit } => Some(unit.as_str()),
3055 _ => None,
3056 })
3057 .collect();
3058 assert!(started.contains(&"demo-blue"), "started: {started:?}");
3059 assert!(!started.contains(&"demo"), "bare unit started: {started:?}");
3060
3061 let env = result
3063 .steps
3064 .iter()
3065 .find_map(|s| match s {
3066 Step::WriteFile(f) if f.path.file_name() == Some(std::ffi::OsStr::new(".env")) => {
3067 Some(&f.content)
3068 }
3069 _ => None,
3070 })
3071 .unwrap();
3072 assert!(env.contains("SERVICE_PORT_HTTP_BLUE="), "env: {env}");
3073 assert!(env.contains("SERVICE_PORT_HTTP_GREEN="), "env: {env}");
3074 }
3075
3076 #[test]
3080 fn blue_green_native_add_syncs_builds_and_starts_blue() {
3081 let tmp = tempfile::tempdir().unwrap();
3082 write_native_registry(tmp.path());
3083 let result = plan_service(tmp.path(), "napp");
3084
3085 let syncs: Vec<String> = result
3087 .steps
3088 .iter()
3089 .filter_map(|s| match s {
3090 Step::SyncDir { dst, .. } => Some(dst.to_string_lossy().into_owned()),
3091 _ => None,
3092 })
3093 .collect();
3094 assert!(
3095 syncs.iter().any(|d| d.ends_with("colors/blue")),
3096 "syncs: {syncs:?}"
3097 );
3098 assert!(
3099 syncs.iter().any(|d| d.ends_with("colors/green")),
3100 "syncs: {syncs:?}"
3101 );
3102 let builds: Vec<String> = result
3103 .steps
3104 .iter()
3105 .filter_map(|s| match s {
3106 Step::Build { dir, .. } => Some(dir.to_string_lossy().into_owned()),
3107 _ => None,
3108 })
3109 .collect();
3110 assert!(
3111 builds.iter().any(|d| d.ends_with("colors/blue")),
3112 "builds: {builds:?}"
3113 );
3114 assert!(
3115 builds.iter().any(|d| d.ends_with("colors/green")),
3116 "builds: {builds:?}"
3117 );
3118
3119 let green_unit = result
3122 .steps
3123 .iter()
3124 .find_map(|s| match s {
3125 Step::WriteFile(f) if f.path.ends_with("napp-green.service") => Some(&f.content),
3126 _ => None,
3127 })
3128 .expect("green unit");
3129 assert!(green_unit.contains("WorkingDirectory="));
3130 assert!(green_unit.contains("colors/green"));
3131 assert!(green_unit.contains("Environment=SERVICE_PORT_HTTP="));
3132 assert!(green_unit.contains("ExecStart=/bin/sh -c 'exec python -m app'"));
3133
3134 let started: Vec<&str> = result
3136 .steps
3137 .iter()
3138 .filter_map(|s| match s {
3139 Step::StartService { unit } => Some(unit.as_str()),
3140 _ => None,
3141 })
3142 .collect();
3143 assert_eq!(started, vec!["napp-blue"], "started: {started:?}");
3144
3145 let env = result
3147 .steps
3148 .iter()
3149 .find_map(|s| match s {
3150 Step::WriteFile(f) if f.path.file_name() == Some(std::ffi::OsStr::new(".env")) => {
3151 Some(&f.content)
3152 }
3153 _ => None,
3154 })
3155 .unwrap();
3156 assert!(env.contains("SERVICE_PORT_HTTP_BLUE="));
3157 assert!(env.contains("SERVICE_PORT_HTTP_GREEN="));
3158 }
3159
3160 #[test]
3166 fn blue_green_native_add_with_url_warns_when_no_caddy() {
3167 let tmp = tempfile::tempdir().unwrap();
3168 write_native_registry(tmp.path());
3169 let result = plan_service_exposed(
3170 tmp.path(),
3171 "napp",
3172 exposure::Exposure::Public {
3173 url: "https://napp.example.com".into(),
3174 },
3175 );
3176 assert!(
3177 result
3178 .warnings
3179 .iter()
3180 .any(|w| matches!(w, Warning::UrlWithoutReverseProxy { .. })),
3181 "native + url + no caddy should warn UrlWithoutReverseProxy"
3182 );
3183 }
3184
3185 #[test]
3188 fn restart_podman_add_is_unchanged() {
3189 let tmp = tempfile::tempdir().unwrap();
3190 write_demo_registry(tmp.path(), "");
3191 let result = plan_demo(tmp.path());
3192 let written: Vec<String> = result
3193 .steps
3194 .iter()
3195 .filter_map(|s| match s {
3196 Step::WriteFile(f) => f
3197 .path
3198 .file_name()
3199 .and_then(|n| n.to_str())
3200 .map(String::from),
3201 _ => None,
3202 })
3203 .collect();
3204 assert!(
3205 written.iter().any(|n| n == "demo.container"),
3206 "got {written:?}"
3207 );
3208 assert!(
3209 !written.iter().any(|n| n.contains("-blue")),
3210 "got {written:?}"
3211 );
3212 let started: Vec<&str> = result
3213 .steps
3214 .iter()
3215 .filter_map(|s| match s {
3216 Step::StartService { unit } => Some(unit.as_str()),
3217 _ => None,
3218 })
3219 .collect();
3220 assert!(started.contains(&"demo"));
3221 }
3222
3223 #[test]
3224 fn static_template_filter_excludes_secrets_and_credentials() {
3225 assert!(is_static_template("3306"));
3227 assert!(is_static_template("mariadb"));
3228 assert!(is_static_template("{{service.port}}"));
3230 assert!(is_static_template("{{service.url}}"));
3231 assert!(is_static_template("{{auth.url}}"));
3232 assert!(is_static_template("{{auth.issuer}}"));
3233 assert!(is_static_template("{{auth.provider}}"));
3234 assert!(is_static_template("{{auth.internal_url}}"));
3235 assert!(is_static_template("{{smtp.host}}"));
3236 assert!(is_static_template("{{smtp.port}}"));
3237 assert!(is_static_template("{{smtp.from}}"));
3238 assert!(is_static_template("{{service.url}}/oauth/callback"));
3240
3241 assert!(!is_static_template("{{secret.admin_password}}"));
3243 assert!(!is_static_template("{{secret.jwt_key}}"));
3244 assert!(!is_static_template("{{auth.client_id}}"));
3246 assert!(!is_static_template("{{auth.client_secret}}"));
3247 assert!(!is_static_template("{{smtp.username}}"));
3249 assert!(!is_static_template("{{smtp.password}}"));
3250 assert!(!is_static_template(
3252 "redis://:{{secret.redis_pw}}@host:6379"
3253 ));
3254 }
3255
3256 #[test]
3257 fn tailscale_url_matches() {
3258 assert!(is_tailscale_url("http://debian.cobbler-tuna.ts.net"));
3259 assert!(is_tailscale_url("http://debian.cobbler-tuna.ts.net:10001/"));
3260 assert!(is_tailscale_url("https://foo.example-net.ts.net"));
3261 assert!(is_tailscale_url("http://HOST.COBBLER-TUNA.TS.NET"));
3262 }
3263
3264 #[test]
3265 fn tailscale_url_rejects() {
3266 assert!(!is_tailscale_url("https://nextcloud.internal:8443"));
3267 assert!(!is_tailscale_url("https://example.com"));
3268 assert!(!is_tailscale_url("http://127.0.0.1:10001"));
3269 assert!(!is_tailscale_url("https://ts.net"));
3271 assert!(!is_tailscale_url("https://evil-ts.net.example.com"));
3272 assert!(!is_tailscale_url("not a url"));
3273 }
3274
3275 #[test]
3276 fn public_url_accepts_public_domains() {
3277 assert!(is_public_url("https://seafile.ryra.no"));
3278 assert!(is_public_url("https://example.com"));
3279 assert!(is_public_url("https://docs.ryra.no:8443"));
3280 }
3281
3282 #[test]
3283 fn public_url_rejects_lan_and_tailnet() {
3284 assert!(!is_public_url("https://nextcloud.internal:8443"));
3285 assert!(!is_public_url("https://service.localhost"));
3286 assert!(!is_public_url("https://something.local"));
3287 assert!(!is_public_url("https://localhost:8080"));
3288 assert!(!is_public_url("https://debian.cobbler-tuna.ts.net"));
3289 assert!(!is_public_url("http://127.0.0.1:10001"));
3290 assert!(!is_public_url("http://192.168.1.10"));
3291 assert!(!is_public_url("http://[::1]"));
3292 assert!(!is_public_url("not a url"));
3293 }
3294
3295 #[test]
3300 fn networks_empty_when_no_auth() {
3301 let nets = resolve_extra_networks(
3302 "whoami", false, false, false, false, false, false, None, false,
3303 );
3304 assert!(nets.is_empty());
3305 }
3306
3307 #[test]
3308 fn networks_empty_when_auth_but_no_authelia() {
3309 let nets = resolve_extra_networks(
3310 "forgejo", true, false, false, false, false, false, None, false,
3311 );
3312 assert!(nets.is_empty());
3313 }
3314
3315 #[test]
3316 fn networks_authelia_when_auth_enabled() {
3317 let nets = resolve_extra_networks(
3318 "forgejo", true, true, false, false, false, false, None, false,
3319 );
3320 assert_eq!(nets, vec!["authelia"]);
3321 }
3322
3323 #[test]
3324 fn networks_auth_with_caddy_includes_both() {
3325 let nets = resolve_extra_networks(
3326 "forgejo", true, true, true, false, false, false, None, false,
3327 );
3328 assert!(nets.contains(&"authelia".to_string()));
3329 assert!(nets.contains(&"caddy".to_string()));
3330 }
3331
3332 #[test]
3333 fn networks_authelia_excluded_for_authelia_itself() {
3334 let nets = resolve_extra_networks(
3335 "authelia", true, true, false, false, false, false, None, false,
3336 );
3337 assert!(nets.is_empty());
3338 }
3339
3340 #[test]
3341 fn networks_smtp_joins_inbucket_without_caddy() {
3342 let nets = resolve_extra_networks(
3344 "forgejo", false, false, false, true, false, true, None, false,
3345 );
3346 assert_eq!(nets, vec!["inbucket"]);
3347 }
3348
3349 #[test]
3350 fn networks_smtp_skips_inbucket_when_it_is_self() {
3351 let nets = resolve_extra_networks(
3352 "inbucket", false, false, false, true, false, true, None, false,
3353 );
3354 assert!(!nets.contains(&"inbucket".to_string()));
3355 }
3356
3357 #[test]
3358 fn networks_smtp_skips_inbucket_when_not_installed() {
3359 let nets = resolve_extra_networks(
3360 "forgejo", false, false, false, false, false, true, None, false,
3361 );
3362 assert!(!nets.contains(&"inbucket".to_string()));
3363 }
3364
3365 #[test]
3366 fn networks_metrics_consumer_joins_store() {
3367 let nets = resolve_extra_networks(
3368 "grafana",
3369 false,
3370 false,
3371 false,
3372 false,
3373 false,
3374 false,
3375 Some("prometheus"),
3376 true,
3377 );
3378 assert_eq!(nets, vec!["prometheus".to_string()]);
3379 }
3380
3381 #[test]
3382 fn networks_metrics_store_skips_itself() {
3383 let nets = resolve_extra_networks(
3384 "prometheus",
3385 false,
3386 false,
3387 false,
3388 false,
3389 false,
3390 false,
3391 Some("prometheus"),
3392 true,
3393 );
3394 assert!(nets.is_empty());
3395 }
3396
3397 #[test]
3398 fn networks_metrics_indifferent_service_skips_store() {
3399 let nets = resolve_extra_networks(
3400 "vaultwarden",
3401 false,
3402 false,
3403 false,
3404 false,
3405 false,
3406 false,
3407 Some("prometheus"),
3408 false,
3409 );
3410 assert!(nets.is_empty());
3411 }
3412
3413 #[test]
3414 fn quadlet_belongs_to_exact_match() {
3415 let all = &["foo", "foo-bar"];
3416 assert!(quadlet_belongs_to("foo.container", "foo", all));
3417 assert!(quadlet_belongs_to("foo.network", "foo", all));
3418 }
3419
3420 #[test]
3421 fn quadlet_belongs_to_sidecar() {
3422 let all = &["foo"];
3424 assert!(quadlet_belongs_to("foo-db.volume", "foo", all));
3425 }
3426
3427 #[test]
3428 fn quadlet_belongs_to_rejects_prefix_collision() {
3429 let all = &["foo", "foo-bar"];
3430 assert!(!quadlet_belongs_to("foo-bar.container", "foo", all));
3431 assert!(!quadlet_belongs_to("foo-bar-db.volume", "foo", all));
3432 }
3433
3434 #[test]
3435 fn quadlet_belongs_to_hyphenated_service() {
3436 let all = &["foo", "foo-bar"];
3437 assert!(quadlet_belongs_to("foo-bar.container", "foo-bar", all));
3438 assert!(quadlet_belongs_to("foo-bar-db.volume", "foo-bar", all));
3439 assert!(!quadlet_belongs_to("foo.container", "foo-bar", all));
3440 }
3441}