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}
480
481fn caddy_route_steps(
490 service_name: &str,
491 url: &str,
492 target_host: String,
493 upstream_port: u16,
494 host_port: Option<u16>,
495 caddy_installed: bool,
496 https_port: u16,
497) -> Result<(Vec<Step>, Vec<Warning>)> {
498 let mut steps = Vec::new();
499 let mut warnings = Vec::new();
500 if caddy_installed {
501 let parsed = url::Url::parse(url)
502 .map_err(|e| Error::Template(format!("invalid service URL '{url}': {e}")))?;
503 let domain = parsed.host_str().ok_or_else(|| {
504 Error::Template(format!(
505 "service URL '{url}' has no host — Caddy needs a hostname to route to"
506 ))
507 })?;
508 let block = caddy::render_site_block(&caddy::CaddySiteParams {
509 service_name: service_name.to_string(),
510 target_host,
511 domain: domain.to_string(),
512 container_port: upstream_port,
513 https_port,
514 force_internal_tls: false,
515 });
516 let caddyfile_path = caddy::caddyfile_path()?;
517 let existing =
518 std::fs::read_to_string(&caddyfile_path).map_err(|source| Error::FileRead {
519 path: caddyfile_path.clone(),
520 source,
521 })?;
522 let updated = caddy::add_route(&existing, service_name, &block);
523 steps.push(Step::WriteFile(GeneratedFile {
524 path: caddyfile_path,
525 content: updated,
526 }));
527 steps.push(Step::ReloadCaddy);
528 } else if let Some(primary) = host_port {
529 warnings.push(Warning::UrlWithoutReverseProxy {
532 service_name: service_name.to_string(),
533 url: url.to_string(),
534 host_port: primary,
535 });
536 }
537 Ok((steps, warnings))
538}
539
540pub fn add_service(params: AddServiceParams<'_>) -> Result<AddResult> {
542 let AddServiceParams {
543 service_name,
544 exposure,
545 auth,
546 enable_smtp,
547 enable_backup,
548 env_overrides,
549 enabled_groups,
550 selected_choices,
551 registry_name,
552 repo_dir,
553 pre_built_ctx,
554 port_in_use,
555 acme_mode,
556 mode,
557 port_overrides,
558 } = params;
559 let auth_kind: Option<®istry::service_def::AuthKind> = auth.native_kind();
564 let enable_auth: bool = auth.enabled();
565 let url: Option<&str> = exposure.url();
566 let paths = ConfigPaths::resolve()?;
567 let config = config::load_or_default(&paths.config_file)?;
568
569 if mode == PlanMode::Add {
575 if is_service_installed(service_name) {
576 return Err(Error::ServiceAlreadyInstalled(service_name.to_string()));
577 }
578
579 if data::enumerate_service(service_name)?.is_some() {
587 return Err(Error::ServiceIncomplete(service_name.to_string()));
588 }
589 }
590
591 let reg_service = registry::find_service(repo_dir, service_name)?;
592
593 if let Some(msg) = reg_service.def.check_architecture() {
595 return Err(Error::UnsupportedArchitecture(msg));
596 }
597
598 let missing_requires: Vec<&str> = reg_service
603 .def
604 .requires
605 .iter()
606 .filter(|r| !is_service_installed(&r.service))
607 .map(|r| r.service.as_str())
608 .collect();
609 if !missing_requires.is_empty() {
610 return Err(Error::MissingRequiredServices {
611 service: service_name.to_string(),
612 missing: missing_requires.iter().map(|s| s.to_string()).collect(),
613 });
614 }
615
616 if auth_kind.is_some() && config.auth.is_none() {
618 return Err(Error::AuthNotConfigured);
619 }
620
621 if enable_auth
625 && reg_service.def.integrations.auth.is_empty()
626 && !capability::def_provides(®_service.def, Capability::OidcProvider)
627 {
628 return Err(Error::NoOidcSupport(service_name.to_string()));
629 }
630
631 if enable_backup && !reg_service.def.integrations.backup {
635 return Err(Error::BackupNotSupported(service_name.to_string()));
636 }
637
638 for g in enabled_groups {
642 if !reg_service.def.env_groups.iter().any(|eg| &eg.name == g) {
643 let known: Vec<String> = reg_service
644 .def
645 .env_groups
646 .iter()
647 .map(|eg| eg.name.clone())
648 .collect();
649 let hint = if known.is_empty() {
650 " (service defines no env_groups)".to_string()
651 } else {
652 format!(" (known: {})", known.join(", "))
653 };
654 return Err(Error::UnknownEnvGroup {
655 service: service_name.to_string(),
656 group: g.clone(),
657 hint,
658 });
659 }
660 }
661
662 let mut port_warnings: Vec<Warning> = Vec::new();
668 let mut effective_ports: Vec<®istry::service_def::PortDef> =
673 reg_service.def.ports.iter().collect();
674 for choice in ®_service.def.choices {
675 let sel = selected_choices
676 .get(&choice.name)
677 .unwrap_or(&choice.default);
678 if let Some(opt) = choice.options.iter().find(|o| &o.name == sel) {
679 effective_ports.extend(opt.ports.iter());
680 }
681 }
682 let mut claimed: std::collections::HashSet<u16> =
683 effective_ports.iter().filter_map(|p| p.host_port).collect();
684 let mut resolved_ports: Vec<(String, u16)> = Vec::with_capacity(effective_ports.len());
685 for p in effective_ports.iter().copied() {
686 let host = if let Some(pinned) = port_overrides.get(&p.name) {
687 *pinned
692 } else if let Some(hp) = p.host_port {
693 hp
694 } else {
695 let privileged = p.container_port < 1024;
696 let claimed_in_service = claimed.contains(&p.container_port);
697 let in_use = port_in_use(p.container_port);
698 if privileged || claimed_in_service || in_use {
699 let allocated = system::port::allocate_port_excluding(&claimed, port_in_use)?;
700 let reason = if privileged {
701 "port is privileged (requires root)".to_string()
702 } else if claimed_in_service {
703 format!(
704 "port {} is already claimed by another port in this service",
705 p.container_port
706 )
707 } else {
708 format!("port {} is already in use", p.container_port)
709 };
710 port_warnings.push(Warning::PortReassigned {
711 service_name: service_name.to_string(),
712 port_name: p.name.clone(),
713 original_port: p.container_port,
714 assigned_port: allocated,
715 reason,
716 });
717 allocated
718 } else {
719 p.container_port
720 }
721 };
722 claimed.insert(host);
723 resolved_ports.push((p.name.clone(), host));
724 }
725
726 let caddy_direct = selected_choices
735 .get("binding")
736 .map(|s| s == "direct")
737 .unwrap_or(false);
738 if WellKnownService::Caddy.matches(service_name)
739 && caddy_direct
740 && system::sysctl::rootless_can_bind_low_ports()
741 {
742 for (name, port) in resolved_ports.iter_mut() {
743 match name.as_str() {
744 "http" if *port == 8080 => *port = 80,
745 "https" if *port == 8443 => *port = 443,
746 _ => {}
747 }
748 }
749 }
750
751 let host_port = resolved_ports
754 .iter()
755 .find(|(name, _)| name.eq_ignore_ascii_case("http"))
756 .or_else(|| resolved_ports.first())
757 .map(|(_, p)| *p);
758
759 for (_, port) in &resolved_ports {
763 if port_in_use(*port) {
764 return Err(Error::PortConflict { port: *port });
765 }
766 }
767
768 let blue_green =
775 reg_service.def.service.deploy == registry::service_def::DeployStrategy::BlueGreen;
776 if blue_green {
777 let primary = resolved_ports
778 .iter()
779 .find(|(name, _)| name.eq_ignore_ascii_case("http"))
780 .or_else(|| resolved_ports.first())
781 .map(|(name, port)| (name.clone(), *port));
782 if let Some((name, blue_port)) = primary {
783 let green_key = format!("{}_green", name.to_ascii_lowercase());
784 let green_port = match port_overrides.get(&green_key) {
785 Some(pinned) => *pinned,
786 None => {
787 let p = system::port::allocate_port_excluding(&claimed, port_in_use)?;
788 claimed.insert(p);
789 p
790 }
791 };
792 resolved_ports.push((format!("{name}_blue"), blue_port));
793 resolved_ports.push((format!("{name}_green"), green_port));
794 }
795 }
796
797 let home_dir = service_home(service_name)?;
798 let quadlet_path = quadlet_dir()?;
799
800 let installed_now = list_installed().unwrap_or_default();
804 let authelia_installed =
805 find_installed_provider(&installed_now, Capability::OidcProvider).is_some();
806 let caddy_installed =
807 find_installed_provider(&installed_now, Capability::ReverseProxy).is_some();
808 let inbucket_installed =
809 find_installed_provider(&installed_now, Capability::SmtpRelay).is_some();
810 let metrics_store =
811 find_installed_provider(&installed_now, Capability::MetricsStore).map(|s| s.name.clone());
812
813 let provides_auth_infra = reg_service
819 .def
820 .capabilities
821 .provides
822 .iter()
823 .any(|c| matches!(c, Capability::OidcProvider | Capability::ReverseProxy));
824 if enable_auth
825 && !provides_auth_infra
826 && !caddy_installed
827 && let Some(authelia) = find_installed_provider(&installed_now, Capability::OidcProvider)
828 && let Some(auth_url) = authelia.exposure.url()
829 {
830 return Err(Error::AuthRequiresReverseProxy {
831 service: service_name.to_string(),
832 auth_url: auth_url.to_string(),
833 });
834 }
835
836 let auth_bridge = auth_bridge::build(&auth_bridge::AuthBridgeParams {
840 service_name,
841 service_provides: ®_service.def.capabilities.provides,
842 enable_auth,
843 config: &config,
844 installed: &installed_now,
845 service_data: &home_dir,
846 })?;
847
848 let (extra_volumes, extra_env, extra_exec_start_pre, auth_bridge_steps) = match auth_bridge {
849 Some(b) => (b.volumes, b.env, b.exec_start_pre, b.steps),
850 None => (Vec::new(), BTreeMap::new(), Vec::new(), Vec::new()),
851 };
852
853 let has_smtp = enable_smtp
854 && reg_service.def.integrations.smtp
855 && !reg_service.def.mappings.smtp.is_empty()
856 && config.smtp.is_some();
857 let wants_metrics = reg_service
860 .def
861 .metrics
862 .as_ref()
863 .is_some_and(|m| !m.host_network)
864 || capability::def_provides(®_service.def, Capability::MetricsDashboard);
865 let extra_networks = resolve_extra_networks(
866 service_name,
867 enable_auth,
868 authelia_installed,
869 caddy_installed,
870 inbucket_installed,
871 url.is_some(),
872 has_smtp,
873 metrics_store.as_deref(),
874 wants_metrics,
875 );
876
877 let output = generate::generate_env(generate::GenerateEnvParams {
878 config: &config,
879 service_def: ®_service.def,
880 auth_kind,
881 host_port,
882 resolved_ports: &resolved_ports,
883 env_overrides,
884 exposure,
885 extra_env,
886 pre_built_ctx,
887 enable_smtp: has_smtp,
888 enabled_groups,
889 selected_choices,
890 })?;
891
892 let podman_args: Vec<String> = Vec::new();
893
894 let port_names: Vec<String> = resolved_ports.iter().map(|(n, _)| n.clone()).collect();
896
897 let active_color = match reg_service.def.service.deploy {
913 registry::service_def::DeployStrategy::BlueGreen => {
914 Some(registry::service_def::Color::Blue)
915 }
916 registry::service_def::DeployStrategy::Restart => None,
917 };
918 let install_metadata = Metadata {
919 registry: registry_name.to_string(),
920 url: url.map(str::to_string),
921 auth: auth_kind.cloned(),
922 provides: reg_service.def.capabilities.provides.clone(),
923 backup_enabled: enable_backup,
924 smtp_enabled: enable_smtp,
925 enabled_groups: enabled_groups.iter().cloned().collect(),
926 selected_choices: selected_choices.clone(),
927 runtime: reg_service.def.service.runtime.clone(),
928 active_color,
929 };
930
931 if reg_service.def.service.runtime == registry::service_def::Runtime::Native {
936 let tracked_envs = collect_static_envs(
937 ®_service.def,
938 &output.ctx,
939 enabled_groups,
940 selected_choices,
941 )?;
942 let allocated_ports = resolved_ports.clone();
943 let generated_secrets = collect_generated_secrets(®_service.def, env_overrides);
944
945 let native_blue_green =
951 reg_service.def.service.deploy == registry::service_def::DeployStrategy::BlueGreen;
952 let mut caddy_steps: Vec<Step> = Vec::new();
953 let mut native_warnings: Vec<Warning> = Vec::new();
954 if let Some(u) = url
955 && !exposure.is_tailscale()
956 {
957 let upstream_port = if native_blue_green {
958 resolved_ports
959 .iter()
960 .find(|(n, _)| n.eq_ignore_ascii_case("http_blue"))
961 .map(|(_, p)| *p)
962 } else {
963 host_port
964 };
965 if let Some(p) = upstream_port {
966 let (route_steps, route_warnings) = caddy_route_steps(
967 service_name,
968 u,
969 "host.containers.internal".to_string(),
970 p,
971 host_port,
972 caddy_installed,
973 caddy_https_port(&config),
974 )?;
975 caddy_steps = route_steps;
976 native_warnings.extend(route_warnings);
977 }
978 }
979
980 return build_native_add(NativeAddParams {
981 service_name,
982 reg_service: ®_service,
983 home_dir: &home_dir,
984 output,
985 install_metadata: &install_metadata,
986 registry_name,
987 url,
988 tracked_envs,
989 allocated_ports,
990 generated_secrets,
991 excluded_quadlets: excluded_quadlets(®_service.def, selected_choices),
992 caddy_steps,
993 warnings: native_warnings,
994 });
995 }
996
997 let excluded_quadlets = excluded_quadlets(®_service.def, selected_choices);
998
999 let bundle =
1001 generate::bundle::process_quadlet_bundle(&generate::bundle::ProcessBundleParams {
1002 service_dir: ®_service.service_dir,
1003 service_name,
1004 extra_networks: &extra_networks,
1005 extra_volumes: &extra_volumes,
1006 podman_args: &podman_args,
1007 extra_exec_start_pre: &extra_exec_start_pre,
1008 port_names: &port_names,
1009 excluded_quadlets: &excluded_quadlets,
1010 })?;
1011
1012 let mut warnings = Vec::new();
1014
1015 if let Some(ref reqs) = reg_service.def.requirements
1016 && let Some(total) = system::memory::total_ram_mb()
1017 {
1018 if total < reqs.ram.min {
1019 warnings.push(Warning::RamBelowMinimum {
1020 service_name: service_name.to_string(),
1021 min_mb: reqs.ram.min,
1022 available_mb: total,
1023 });
1024 } else if let Some(rec) = reqs.ram.recommended
1025 && total < rec
1026 {
1027 warnings.push(Warning::RamBelowRecommended {
1028 service_name: service_name.to_string(),
1029 recommended_mb: rec,
1030 available_mb: total,
1031 });
1032 }
1033 }
1034 warnings.extend(port_warnings);
1035
1036 let mut steps = Vec::new();
1038
1039 steps.push(Step::CreateDir(home_dir.clone()));
1041
1042 let env_content = output.env_file.content.clone();
1044
1045 for image in &bundle.images {
1047 steps.push(Step::PullImage {
1048 image: image.clone(),
1049 });
1050 }
1051
1052 let quadlet_files = if blue_green {
1058 deploy::expand_color_quadlets(bundle.quadlet_files, service_name)
1059 } else {
1060 bundle.quadlet_files
1061 };
1062 for file in quadlet_files {
1063 let link = file
1064 .path
1065 .file_name()
1066 .map(|n| quadlet_path.join(n))
1067 .ok_or_else(|| {
1068 Error::Bundle(format!("invalid quadlet path: {}", file.path.display()))
1069 })?;
1070 let target = file.path.clone();
1071 steps.push(Step::WriteFile(file));
1072 steps.push(Step::Symlink { link, target });
1073 }
1074
1075 let metadata_content = toml::to_string_pretty(&install_metadata)?;
1082 steps.push(Step::WriteFile(GeneratedFile {
1083 path: metadata_path(service_name)?,
1084 content: metadata_content,
1085 }));
1086
1087 if mode == PlanMode::Add && exposure.is_tailscale() {
1095 let svc_name = exposure.tailscale_svc_name().ok_or_else(|| {
1105 Error::InvalidServiceRef(format!(
1106 "tailscale exposure for '{service_name}' has a malformed URL — \
1107 expected `https://<service>-<host>.<tailnet>.ts.net/`"
1108 ))
1109 })?;
1110 let ts_ports = plan::tailscale_ports(®_service.def.ports, &resolved_ports, host_port);
1114 if !ts_ports.is_empty() {
1115 steps.push(Step::TailscaleSetup);
1116 steps.push(Step::TailscaleEnable {
1117 svc_name,
1118 ports: ts_ports,
1119 });
1120 }
1121 }
1122
1123 for file in bundle.config_files {
1125 steps.push(Step::WriteFile(file));
1126 }
1127
1128 for (src, dst) in bundle.files {
1132 steps.push(Step::CopyFile { src, dst });
1133 }
1134
1135 steps.push(Step::WriteFile(output.env_file));
1137
1138 for dir in &bundle.bind_mount_dirs {
1140 steps.push(Step::CreateDir(dir.clone()));
1141 }
1142
1143 steps.extend(auth_bridge_steps);
1147
1148 if mode == PlanMode::Add
1157 && let (
1158 Some(registry::service_def::AuthKind::Oidc),
1159 Some(config::schema::AuthCredentials::Authelia { .. }),
1160 ) = (auth_kind, config.auth.as_ref())
1161 {
1162 steps.extend(authelia::register_oidc_client(
1163 service_name,
1164 ®_service.def,
1165 url,
1166 &output.ctx,
1167 &quadlet_path,
1168 )?);
1169 }
1170
1171 if let Some(url) = url
1177 && !WellKnownService::Caddy.matches(service_name)
1178 && !exposure.is_tailscale()
1179 {
1180 let container_port = reg_service
1181 .def
1182 .ports
1183 .first()
1184 .map(|p| p.container_port)
1185 .unwrap_or(80);
1186 let primary_quadlet = reg_service
1187 .service_dir
1188 .join("quadlets")
1189 .join(format!("{service_name}.container"));
1190 let target_host = if blue_green {
1193 deploy::color_unit(service_name, registry::service_def::Color::Blue)
1194 } else {
1195 caddy::primary_container_name(&primary_quadlet, service_name)
1196 };
1197 let (route_steps, route_warnings) = caddy_route_steps(
1198 service_name,
1199 url,
1200 target_host,
1201 container_port,
1202 host_port,
1203 caddy_installed,
1204 caddy_https_port(&config),
1205 )?;
1206 steps.extend(route_steps);
1207 warnings.extend(route_warnings);
1208 }
1209
1210 if mode == PlanMode::Add {
1220 steps.extend(retroactive_network_joins(
1221 service_name,
1222 &quadlet_path,
1223 Some(repo_dir),
1224 ));
1225 }
1226
1227 if mode == PlanMode::Add {
1234 if let Some(store) = &metrics_store {
1235 let metrics_host_port = reg_service.def.metrics.as_ref().and_then(|m| {
1236 resolved_ports
1237 .iter()
1238 .find(|(n, _)| n == &m.port)
1239 .map(|(_, p)| *p)
1240 });
1241 if let Some(step) =
1242 metrics_bridge::scrape_target_step(store, ®_service.def, metrics_host_port)?
1243 {
1244 steps.push(step);
1245 }
1246 if capability::def_provides(®_service.def, Capability::MetricsDashboard)
1247 && let Some(port) = store_container_port(store)
1248 {
1249 steps.push(metrics_bridge::datasource_step(service_name, store, port)?);
1250 }
1251 }
1252 if capability::def_provides(®_service.def, Capability::MetricsStore) {
1253 steps.extend(retroactive_metrics_wiring(
1254 service_name,
1255 ®_service.def,
1256 &quadlet_path,
1257 ));
1258 }
1259 }
1260
1261 if WellKnownService::Caddy.matches(service_name) {
1268 let snippet_path = caddy::tls_snippet_path()?;
1269 if !snippet_path.exists() {
1270 let mode = acme_mode.cloned().unwrap_or(caddy::AcmeMode::Internal);
1271 steps.push(Step::WriteFile(GeneratedFile {
1272 path: snippet_path,
1273 content: mode.snippet(),
1274 }));
1275 }
1276 }
1277
1278 let manifest_path_for_svc = manifest::manifest_path(service_name)?;
1287 let env_filename = std::ffi::OsStr::new(".env");
1288 let mut manifest_entries: Vec<manifest::ManifestEntry> = Vec::new();
1289 for step in &steps {
1290 if let Step::WriteFile(file) = step {
1291 if file.path == manifest_path_for_svc {
1292 continue;
1293 }
1294 if file.path.file_name() == Some(env_filename) {
1295 continue;
1296 }
1297 manifest_entries.push(manifest::ManifestEntry {
1298 path: file.path.clone(),
1299 sha256: manifest::hash_bytes(file.content.as_bytes()),
1300 });
1301 }
1302 }
1303 let tracked_envs = collect_static_envs(
1311 ®_service.def,
1312 &output.ctx,
1313 enabled_groups,
1314 selected_choices,
1315 )?;
1316 let manifest_envs: Vec<manifest::EnvEntry> = tracked_envs
1317 .iter()
1318 .map(|t| manifest::EnvEntry {
1319 key: t.key.clone(),
1320 value: t.value.clone(),
1321 })
1322 .collect();
1323 steps.push(Step::WriteFile(GeneratedFile {
1324 path: manifest_path_for_svc,
1325 content: manifest::format(&manifest_entries, &manifest_envs),
1326 }));
1327
1328 steps.push(Step::DaemonReload);
1330 let start_unit = if blue_green {
1334 deploy::color_unit(service_name, registry::service_def::Color::Blue)
1335 } else {
1336 service_name.to_string()
1337 };
1338 steps.push(Step::StartService { unit: start_unit });
1339
1340 let allocated_ports: Vec<(String, u16)> = resolved_ports.clone();
1342
1343 let mut generated_secrets: Vec<String> = reg_service
1345 .def
1346 .env
1347 .iter()
1348 .filter(|e| !env_overrides.contains_key(&e.name))
1349 .flat_map(|e| generate::extract_secret_refs(&e.value))
1350 .collect();
1351 generated_secrets.sort();
1353 generated_secrets.dedup();
1354
1355 Ok(AddResult {
1356 steps,
1357 warnings,
1358 repo_url: registry_name.to_string(),
1359 allocated_ports,
1360 generated_secrets,
1361 env_content,
1362 url: url.map(|u| u.to_string()),
1363 tracked_envs,
1364 })
1365}
1366
1367fn collect_generated_secrets(
1370 def: ®istry::service_def::ServiceDef,
1371 env_overrides: &BTreeMap<String, String>,
1372) -> Vec<String> {
1373 let mut out: Vec<String> = def
1374 .env
1375 .iter()
1376 .filter(|e| !env_overrides.contains_key(&e.name))
1377 .flat_map(|e| generate::extract_secret_refs(&e.value))
1378 .collect();
1379 out.sort();
1380 out.dedup();
1381 out
1382}
1383
1384struct NativeAddParams<'a> {
1386 service_name: &'a str,
1387 reg_service: &'a registry::RegistryService,
1388 home_dir: &'a Path,
1389 output: generate::EnvOutput,
1390 install_metadata: &'a Metadata,
1391 registry_name: &'a str,
1392 url: Option<&'a str>,
1393 tracked_envs: Vec<TrackedEnv>,
1394 allocated_ports: Vec<(String, u16)>,
1395 generated_secrets: Vec<String>,
1396 excluded_quadlets: Vec<String>,
1400 caddy_steps: Vec<Step>,
1404 warnings: Vec<Warning>,
1407}
1408
1409fn excluded_quadlets(
1413 def: ®istry::service_def::ServiceDef,
1414 selected_choices: &BTreeMap<String, String>,
1415) -> Vec<String> {
1416 let mut all_claimed: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
1417 let mut selected: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
1418 for choice in &def.choices {
1419 let picked = selected_choices
1420 .get(&choice.name)
1421 .unwrap_or(&choice.default);
1422 for option in &choice.options {
1423 for q in &option.quadlets {
1424 all_claimed.insert(q.clone());
1425 if &option.name == picked {
1426 selected.insert(q.clone());
1427 }
1428 }
1429 }
1430 }
1431 all_claimed.difference(&selected).cloned().collect()
1432}
1433
1434fn build_native_add(p: NativeAddParams<'_>) -> Result<AddResult> {
1440 let NativeAddParams {
1441 service_name,
1442 reg_service,
1443 home_dir,
1444 output,
1445 install_metadata,
1446 registry_name,
1447 url,
1448 tracked_envs,
1449 allocated_ports,
1450 generated_secrets,
1451 excluded_quadlets,
1452 caddy_steps,
1453 warnings,
1454 } = p;
1455
1456 let run = reg_service.def.service.run.as_ref().ok_or_else(|| {
1457 Error::Bundle(format!(
1458 "native service '{service_name}' is missing its `run` command"
1459 ))
1460 })?;
1461 let build = reg_service.def.service.build.as_ref();
1462
1463 let env_content = output.env_file.content.clone();
1464 let source_dir = reg_service.service_dir.clone();
1465 let mut steps = Vec::new();
1466
1467 steps.push(Step::CreateDir(home_dir.to_path_buf()));
1472 steps.push(Step::CreateDir(home_dir.join("data")));
1473
1474 let blue_green =
1475 reg_service.def.service.deploy == registry::service_def::DeployStrategy::BlueGreen;
1476
1477 steps.push(Step::WriteFile(GeneratedFile {
1480 path: metadata_path(service_name)?,
1481 content: toml::to_string_pretty(install_metadata)?,
1482 }));
1483 steps.push(Step::WriteFile(output.env_file));
1484
1485 let description = reg_service.def.service.description.as_str();
1486 if blue_green {
1487 let primary = allocated_ports
1493 .iter()
1494 .find(|(n, _)| n.eq_ignore_ascii_case("http"))
1495 .or_else(|| allocated_ports.first())
1496 .map(|(n, _)| n.clone())
1497 .ok_or_else(|| {
1498 Error::Bundle(format!(
1499 "blue/green native '{service_name}' has no port to route"
1500 ))
1501 })?;
1502 let port_var = format!("SERVICE_PORT_{}", primary.to_uppercase());
1503 let home_str = home_dir.to_string_lossy().into_owned();
1504 for color in [
1505 registry::service_def::Color::Blue,
1506 registry::service_def::Color::Green,
1507 ] {
1508 let slot = home_dir.join("colors").join(color.as_str());
1509 let slot_str = slot.to_string_lossy().into_owned();
1510 let port = allocated_ports
1511 .iter()
1512 .find(|(n, _)| *n == format!("{}_{}", primary.to_ascii_lowercase(), color))
1513 .map(|(_, p)| *p)
1514 .ok_or_else(|| {
1515 Error::Bundle(format!(
1516 "blue/green native '{service_name}' missing the {color} port"
1517 ))
1518 })?;
1519 steps.push(Step::SyncDir {
1521 src: source_dir.clone(),
1522 dst: slot.clone(),
1523 });
1524 if let Some(command) = build {
1525 steps.push(Step::Build {
1526 dir: slot.clone(),
1527 command: command.clone(),
1528 });
1529 }
1530 let unit_name = format!("{}.service", deploy::color_unit(service_name, color));
1531 let unit_path = home_dir.join(&unit_name);
1532 steps.push(Step::WriteFile(GeneratedFile {
1533 path: unit_path.clone(),
1534 content: deploy::native_color_unit(&deploy::NativeColorUnit {
1535 description,
1536 color,
1537 workdir: &slot_str,
1538 home: &home_str,
1539 port_var: &port_var,
1540 port,
1541 run,
1542 }),
1543 }));
1544 steps.push(Step::Symlink {
1545 link: systemd_user_dir()?.join(&unit_name),
1546 target: unit_path,
1547 });
1548 }
1549 } else {
1550 if let Some(command) = build {
1552 steps.push(Step::Build {
1553 dir: source_dir.clone(),
1554 command: command.clone(),
1555 });
1556 }
1557 let unit_name = format!("{service_name}.service");
1560 let unit_path = home_dir.join(&unit_name);
1561 steps.push(Step::WriteFile(GeneratedFile {
1562 path: unit_path.clone(),
1563 content: native_unit(home_dir, &source_dir, run, description),
1564 }));
1565 steps.push(Step::Symlink {
1566 link: systemd_user_dir()?.join(&unit_name),
1567 target: unit_path,
1568 });
1569 }
1570
1571 let mut quadlet_units: Vec<String> = Vec::new();
1578 if source_dir.join("quadlets").is_dir() {
1579 let port_names: Vec<String> = allocated_ports.iter().map(|(n, _)| n.clone()).collect();
1582 let bundle =
1583 generate::bundle::process_quadlet_bundle(&generate::bundle::ProcessBundleParams {
1584 service_dir: &source_dir,
1585 service_name,
1586 extra_networks: &[],
1587 extra_volumes: &[],
1588 podman_args: &[],
1589 extra_exec_start_pre: &[],
1590 port_names: &port_names,
1591 excluded_quadlets: &excluded_quadlets,
1592 })?;
1593 for image in &bundle.images {
1594 steps.push(Step::PullImage {
1595 image: image.clone(),
1596 });
1597 }
1598 for dir in &bundle.bind_mount_dirs {
1599 steps.push(Step::CreateDir(dir.clone()));
1600 }
1601 let quadlet_path = quadlet_dir()?;
1602 for file in bundle.quadlet_files {
1603 let fname = file
1604 .path
1605 .file_name()
1606 .ok_or_else(|| {
1607 Error::Bundle(format!("invalid quadlet path: {}", file.path.display()))
1608 })?
1609 .to_os_string();
1610 if let Some(stem) = fname.to_string_lossy().strip_suffix(".container") {
1611 quadlet_units.push(stem.to_string());
1612 }
1613 let link = quadlet_path.join(&fname);
1614 let target = file.path.clone();
1615 steps.push(Step::WriteFile(file));
1616 steps.push(Step::Symlink { link, target });
1617 }
1618 }
1619
1620 steps.push(Step::DaemonReload);
1621 for unit in &quadlet_units {
1623 steps.push(Step::StartService { unit: unit.clone() });
1624 }
1625 let app_unit = if blue_green {
1628 deploy::color_unit(service_name, registry::service_def::Color::Blue)
1629 } else {
1630 service_name.to_string()
1631 };
1632 steps.push(Step::EnableService {
1638 unit: app_unit.clone(),
1639 });
1640 steps.push(Step::StartService { unit: app_unit });
1641
1642 steps.extend(caddy_steps);
1645
1646 Ok(AddResult {
1647 steps,
1648 warnings,
1649 repo_url: registry_name.to_string(),
1650 allocated_ports,
1651 generated_secrets,
1652 env_content,
1653 url: url.map(|u| u.to_string()),
1654 tracked_envs,
1655 })
1656}
1657
1658fn native_unit(home_dir: &Path, source_dir: &Path, run: &str, description: &str) -> String {
1663 let home = home_dir.display();
1664 let source = source_dir.display();
1665 format!(
1674 "[Unit]\n\
1675 Description={description}\n\
1676 After=network.target\n\
1677 \n\
1678 [Service]\n\
1679 Type=simple\n\
1680 WorkingDirectory={source}\n\
1681 EnvironmentFile={home}/.env\n\
1682 Environment=SERVICE_HOME={home}\n\
1683 Environment=PATH=%h/.local/bin:%h/.cargo/bin:%h/.bun/bin:%h/.deno/bin:%h/go/bin:/usr/local/bin:/usr/bin:/bin\n\
1684 ExecStart=/bin/sh -c 'exec {run}'\n\
1685 Restart=always\n\
1686 RestartSec=5\n\
1687 \n\
1688 [Install]\n\
1689 WantedBy=default.target\n",
1690 )
1691}
1692
1693pub fn quadlet_belongs_to(filename: &str, service_name: &str, all_service_names: &[&str]) -> bool {
1702 if !filename.starts_with(service_name) {
1703 return false;
1704 }
1705 let rest = &filename[service_name.len()..];
1706 if rest.starts_with('.') {
1707 return true;
1708 }
1709 if !rest.starts_with('-') {
1710 return false;
1711 }
1712 !all_service_names.iter().any(|&other| {
1716 other.len() > service_name.len()
1717 && other.starts_with(service_name)
1718 && filename.starts_with(other)
1719 && filename[other.len()..].starts_with(['.', '-'])
1720 })
1721}
1722
1723#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
1725#[serde(rename_all = "snake_case")]
1726pub enum RemoveMode {
1727 #[default]
1728 Preserve,
1733 Purge,
1735}
1736
1737pub fn remove_service(service_name: &str, mode: RemoveMode) -> Result<RemoveResult> {
1739 let installed_owned = build_installed_from_metadata(service_name)
1742 .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
1743 let installed = &installed_owned;
1744
1745 if let Ok(Some(meta)) = metadata::load_metadata(service_name)
1750 && meta.runtime == registry::service_def::Runtime::Native
1751 {
1752 let url = installed.exposure.url().map(|s| s.to_string());
1753 return remove_native_service(service_name, mode, url);
1754 }
1755
1756 let quadlet_path = quadlet_dir()?;
1759 let mut steps = Vec::new();
1760 let mut volume_names = Vec::new();
1761 let mut networks: Vec<String> = Vec::new();
1762 let mut has_named_volumes = false;
1763 let name_pool = scan_managed_services().unwrap_or_default();
1767 let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
1768
1769 if let Some(svc_name) = installed.exposure.tailscale_svc_name() {
1781 steps.push(Step::TailscaleDisable { svc_name });
1782 }
1783
1784 if quadlet_path.is_dir()
1785 && let Ok(entries) = std::fs::read_dir(&quadlet_path)
1786 {
1787 for entry in entries.flatten() {
1788 let file_name = entry.file_name();
1789 let name = file_name.to_string_lossy();
1790 if !quadlet_belongs_to(&name, service_name, &all_names) {
1793 continue;
1794 }
1795 if name.ends_with(".container") {
1797 let unit = name.trim_end_matches(".container").to_string();
1798 steps.push(Step::StopService { unit });
1799 }
1800 if name.ends_with(".network") {
1801 let net = name.trim_end_matches(".network").to_string();
1804 steps.push(Step::StopService {
1805 unit: format!("{net}-network"),
1806 });
1807 networks.push(net);
1808 }
1809 if name.ends_with(".volume") {
1810 has_named_volumes = true;
1811 if matches!(mode, RemoveMode::Purge) {
1812 let vol = name.trim_end_matches(".volume").to_string();
1813 volume_names.push(format!("systemd-{vol}"));
1815 }
1816 }
1817 steps.push(Step::RemoveFile(entry.path()));
1818 }
1819 }
1820
1821 let had_caddy_route = matches!(
1828 installed.exposure,
1829 Exposure::Internal { .. } | Exposure::Public { .. }
1830 );
1831 if !WellKnownService::Caddy.matches(service_name) && had_caddy_route {
1832 let caddyfile_path = caddy::caddyfile_path()?;
1833 if caddyfile_path.exists() {
1834 let existing =
1835 std::fs::read_to_string(&caddyfile_path).map_err(|source| Error::FileRead {
1836 path: caddyfile_path.clone(),
1837 source,
1838 })?;
1839 let updated = caddy::remove_route(&existing, service_name);
1840 if updated != existing {
1841 steps.push(Step::WriteFile(GeneratedFile {
1842 path: caddyfile_path,
1843 content: updated.clone(),
1844 }));
1845 if !updated.trim().is_empty() {
1848 steps.push(Step::ReloadCaddy);
1849 }
1850 }
1851 }
1852 }
1853
1854 if !WellKnownService::Authelia.matches(service_name)
1855 && matches!(
1856 installed.auth_kind,
1857 Some(registry::service_def::AuthKind::Oidc)
1858 )
1859 {
1860 steps.extend(authelia::unregister_oidc_client(service_name)?);
1861 }
1862
1863 let installed_all = list_installed().unwrap_or_default();
1869 for store in installed_all
1870 .iter()
1871 .filter(|s| installed_provides(s, Capability::MetricsStore))
1872 {
1873 if store.name != service_name
1874 && let Ok(target) = metrics_bridge::target_file_path(&store.name, service_name)
1875 && target.exists()
1876 {
1877 steps.push(Step::RemoveFile(target));
1878 }
1879 }
1880 if installed.provides.contains(&Capability::MetricsStore) {
1881 for dash in installed_all
1882 .iter()
1883 .filter(|s| installed_provides(s, Capability::MetricsDashboard))
1884 {
1885 if dash.name == service_name {
1886 continue;
1887 }
1888 if let Ok(ds) = metrics_bridge::datasource_file_path(&dash.name, service_name)
1889 && ds.exists()
1890 {
1891 steps.push(Step::RemoveFile(ds));
1892 steps.push(Step::RestartService {
1893 unit: dash.name.clone(),
1894 });
1895 }
1896 }
1897 }
1898
1899 steps.push(Step::DaemonReload);
1901
1902 for net in networks {
1910 steps.push(Step::RemoveNetwork { name: net });
1911 }
1912
1913 match mode {
1914 RemoveMode::Purge => {
1915 for vol_name in volume_names {
1917 steps.push(Step::RemoveVolume { name: vol_name });
1918 }
1919 steps.push(Step::RemoveDir(service_home(service_name)?));
1921 }
1922 RemoveMode::Preserve => {
1923 let home = service_home(service_name)?;
1927 let (data, ephemeral) = crate::data::classify::classify_home_dir(&home)?;
1928 for path in ephemeral {
1929 match std::fs::metadata(&path) {
1930 Ok(m) if m.is_dir() => steps.push(Step::RemoveDir(path)),
1931 Ok(_) => steps.push(Step::RemoveFile(path)),
1932 Err(_) => steps.push(Step::RemoveFile(path)),
1936 }
1937 }
1938 if data.is_empty() && !has_named_volumes && home.exists() {
1946 steps.push(Step::RemoveDir(home));
1947 }
1948 }
1949 }
1950
1951 let url = installed.exposure.url().map(|s| s.to_string());
1952
1953 Ok(RemoveResult {
1954 steps,
1955 service_name: service_name.to_string(),
1956 url,
1957 })
1958}
1959
1960fn remove_native_service(
1965 service_name: &str,
1966 mode: RemoveMode,
1967 url: Option<String>,
1968) -> Result<RemoveResult> {
1969 let home = service_home(service_name)?;
1970 let unit_dir = systemd_user_dir()?;
1974 let unit_names: Vec<String> = [
1975 format!("{service_name}.service"),
1976 format!("{service_name}-blue.service"),
1977 format!("{service_name}-green.service"),
1978 ]
1979 .into_iter()
1980 .filter(|u| unit_dir.join(u).exists())
1981 .collect();
1982 let mut steps = Vec::new();
1983
1984 let mut aux_container_files: Vec<String> = Vec::new();
1990 if let Ok(qdir) = quadlet_dir() {
1991 let names = scan_managed_services().unwrap_or_default();
1992 let all: Vec<&str> = names.iter().map(|s| s.as_str()).collect();
1993 if let Ok(entries) = std::fs::read_dir(&qdir) {
1994 for entry in entries.flatten() {
1995 let fname = entry.file_name().to_string_lossy().into_owned();
1996 if let Some(stem) = fname.strip_suffix(".container")
1997 && quadlet_belongs_to(&fname, service_name, &all)
1998 {
1999 steps.push(Step::StopService {
2000 unit: stem.to_string(),
2001 });
2002 steps.push(Step::RemoveFile(qdir.join(&fname)));
2003 aux_container_files.push(fname);
2004 }
2005 }
2006 }
2007 }
2008
2009 for unit_name in &unit_names {
2010 let bare = unit_name.trim_end_matches(".service").to_string();
2011 steps.push(Step::DisableService { unit: bare.clone() });
2014 steps.push(Step::StopService { unit: bare });
2015 steps.push(Step::RemoveFile(unit_dir.join(unit_name)));
2016 }
2017 steps.push(Step::DaemonReload);
2018
2019 match mode {
2020 RemoveMode::Purge => steps.push(Step::RemoveDir(home)),
2021 RemoveMode::Preserve => {
2022 let mut ephemeral: Vec<String> = vec!["bin".into(), ".env".into(), "colors".into()];
2027 ephemeral.extend(unit_names.iter().cloned());
2028 for child in &ephemeral {
2029 let p = home.join(child);
2030 match std::fs::metadata(&p) {
2031 Ok(m) if m.is_dir() => steps.push(Step::RemoveDir(p)),
2032 Ok(_) => steps.push(Step::RemoveFile(p)),
2033 Err(_) => {} }
2035 }
2036 for f in &aux_container_files {
2037 steps.push(Step::RemoveFile(home.join(f)));
2038 }
2039 }
2040 }
2041
2042 Ok(RemoveResult {
2043 steps,
2044 service_name: service_name.to_string(),
2045 url,
2046 })
2047}
2048
2049#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2051#[serde(rename_all = "snake_case")]
2052pub enum Lifecycle {
2053 Start,
2054 Stop,
2055}
2056
2057pub fn lifecycle_steps(service_name: &str, action: Lifecycle) -> Result<Vec<Step>> {
2066 build_installed_from_metadata(service_name)
2068 .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
2069
2070 if matches!(
2073 metadata::load_metadata(service_name),
2074 Ok(Some(m)) if m.runtime == registry::service_def::Runtime::Native
2075 ) {
2076 let unit = service_name.to_string();
2077 return Ok(vec![match action {
2078 Lifecycle::Start => Step::StartService { unit },
2079 Lifecycle::Stop => Step::StopService { unit },
2080 }]);
2081 }
2082
2083 let mut units = service_container_units(service_name)?;
2084 match action {
2085 Lifecycle::Stop => units.sort_by_key(|u| u != service_name),
2087 Lifecycle::Start => units.sort_by_key(|u| u == service_name),
2089 }
2090
2091 Ok(units
2092 .into_iter()
2093 .map(|unit| match action {
2094 Lifecycle::Start => Step::StartService { unit },
2095 Lifecycle::Stop => Step::StopService { unit },
2096 })
2097 .collect())
2098}
2099
2100fn service_container_units(service_name: &str) -> Result<Vec<String>> {
2104 let quadlet_path = quadlet_dir()?;
2105 let name_pool = scan_managed_services().unwrap_or_default();
2106 let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
2107
2108 let mut units = Vec::new();
2109 if quadlet_path.is_dir()
2110 && let Ok(entries) = std::fs::read_dir(&quadlet_path)
2111 {
2112 for entry in entries.flatten() {
2113 let file_name = entry.file_name();
2114 let name = file_name.to_string_lossy();
2115 if !quadlet_belongs_to(&name, service_name, &all_names) {
2116 continue;
2117 }
2118 if name.ends_with(".container") {
2119 units.push(name.trim_end_matches(".container").to_string());
2120 }
2121 }
2122 }
2123 Ok(units)
2124}
2125
2126pub struct RecordPendingParams<'a> {
2128 pub service_name: &'a str,
2129 pub auth_kind: Option<registry::service_def::AuthKind>,
2130 pub registry_name: &'a str,
2131 pub allocated_ports: &'a [(String, u16)],
2132 pub repo_dir: &'a Path,
2133 pub exposure: &'a Exposure,
2140}
2141
2142pub fn record_pending(params: RecordPendingParams<'_>) -> Result<()> {
2149 let paths = ConfigPaths::resolve()?;
2150 paths.ensure_dirs()?;
2151 let mut config = config::load_or_default(&paths.config_file)?;
2152
2153 if WellKnownService::Authelia.matches(params.service_name) {
2158 config.auth = Some(authelia::auth_config(
2159 params.allocated_ports,
2160 params.exposure.url(),
2161 )?);
2162 config::save_config(&paths.config_file, &config)?;
2163 }
2164
2165 Ok(())
2166}
2167
2168pub fn finalize_remove(service_name: &str) -> Result<()> {
2175 let paths = ConfigPaths::resolve()?;
2176 let mut config = config::load_or_default(&paths.config_file)?;
2177
2178 if WellKnownService::Authelia.matches(service_name)
2179 && let Some(auth) = &config.auth
2180 && auth.provider_name() == "authelia"
2181 {
2182 config.auth = None;
2183 config::save_config(&paths.config_file, &config)?;
2184 }
2185
2186 Ok(())
2187}
2188
2189const SENSITIVE_TEMPLATE_REFS: &[&str] = &[
2208 "{{secret.",
2209 "{{auth.client_id",
2210 "{{auth.client_secret",
2211 "{{smtp.username",
2212 "{{smtp.password",
2213];
2214
2215fn is_static_template(value: &str) -> bool {
2216 !SENSITIVE_TEMPLATE_REFS.iter().any(|s| value.contains(s))
2217}
2218
2219fn collect_static_envs(
2235 service_def: ®istry::service_def::ServiceDef,
2236 ctx: &BTreeMap<String, String>,
2237 enabled_groups: &std::collections::BTreeSet<String>,
2238 selected_choices: &BTreeMap<String, String>,
2239) -> Result<Vec<plan::TrackedEnv>> {
2240 use registry::service_def::EnvKind;
2241 let mut out: Vec<plan::TrackedEnv> = Vec::new();
2242 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
2243 let push = |name: &str,
2244 value_template: &str,
2245 kind: EnvKind,
2246 prompt: Option<String>,
2247 out: &mut Vec<plan::TrackedEnv>,
2248 seen: &mut std::collections::HashSet<String>|
2249 -> Result<()> {
2250 if !is_static_template(value_template) {
2251 return Ok(());
2252 }
2253 if !seen.insert(name.to_string()) {
2254 return Ok(());
2255 }
2256 let value = generate::template::render(value_template, ctx)?;
2257 out.push(plan::TrackedEnv {
2258 key: name.to_string(),
2259 value,
2260 kind,
2261 prompt,
2262 });
2263 Ok(())
2264 };
2265 for env in &service_def.env {
2266 push(
2267 &env.name,
2268 &env.value,
2269 env.kind.clone(),
2270 env.prompt.clone(),
2271 &mut out,
2272 &mut seen,
2273 )?;
2274 }
2275 for group in &service_def.env_groups {
2276 if !enabled_groups.contains(&group.name) {
2277 continue;
2278 }
2279 for env in &group.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 }
2290 for choice in &service_def.choices {
2293 let selected = selected_choices
2294 .get(&choice.name)
2295 .unwrap_or(&choice.default);
2296 let Some(option) = choice.options.iter().find(|o| &o.name == selected) else {
2297 continue;
2298 };
2299 for env in &option.env {
2300 push(
2301 &env.name,
2302 &env.value,
2303 env.kind.clone(),
2304 env.prompt.clone(),
2305 &mut out,
2306 &mut seen,
2307 )?;
2308 }
2309 }
2310 if service_def.integrations.smtp && ctx.contains_key("smtp.host") {
2316 for (env_name, value_template) in &service_def.mappings.smtp {
2317 push(
2318 env_name,
2319 value_template,
2320 EnvKind::Default,
2321 None,
2322 &mut out,
2323 &mut seen,
2324 )?;
2325 }
2326 }
2327 if ctx.contains_key("auth.client_id") {
2328 for (env_name, value_template) in &service_def.mappings.auth {
2329 push(
2330 env_name,
2331 value_template,
2332 EnvKind::Default,
2333 None,
2334 &mut out,
2335 &mut seen,
2336 )?;
2337 }
2338 }
2339 Ok(out)
2340}
2341
2342pub fn orphan_purge_steps(svc: &data::ServiceData) -> Vec<Step> {
2343 let mut steps = Vec::new();
2344
2345 let mut had_quadlet = false;
2351 let mut networks: Vec<String> = Vec::new();
2352 if let Ok(qdir) = quadlet_dir()
2353 && qdir.is_dir()
2354 && let Ok(entries) = std::fs::read_dir(&qdir)
2355 {
2356 let name_pool = scan_managed_services().unwrap_or_default();
2357 let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
2358 for entry in entries.flatten() {
2359 let file_name = entry.file_name();
2360 let name = file_name.to_string_lossy();
2361 if !quadlet_belongs_to(&name, &svc.service, &all_names) {
2362 continue;
2363 }
2364 if name.ends_with(".container") {
2368 let unit = name.trim_end_matches(".container").to_string();
2369 steps.push(Step::StopService { unit });
2370 } else if name.ends_with(".network") {
2371 let net = name.trim_end_matches(".network").to_string();
2372 steps.push(Step::StopService {
2373 unit: format!("{net}-network"),
2374 });
2375 networks.push(net);
2376 } else if name.ends_with(".volume") {
2377 let unit = format!("{}-volume", name.trim_end_matches(".volume"));
2378 steps.push(Step::StopService { unit });
2379 }
2380 steps.push(Step::RemoveFile(entry.path()));
2381 had_quadlet = true;
2382 }
2383 }
2384 if had_quadlet {
2385 steps.push(Step::DaemonReload);
2386 }
2387 for net in networks {
2389 steps.push(Step::RemoveNetwork { name: net });
2390 }
2391
2392 for path in &svc.data_paths {
2393 if path.is_dir() {
2394 steps.push(Step::RemoveDir(path.clone()));
2395 } else {
2396 steps.push(Step::RemoveFile(path.clone()));
2397 }
2398 }
2399 if svc.home_dir.exists() {
2400 steps.push(Step::RemoveDir(svc.home_dir.clone()));
2401 }
2402 for v in &svc.volumes {
2403 steps.push(Step::RemoveVolume {
2404 name: v.name.clone(),
2405 });
2406 }
2407 steps
2408}
2409
2410pub fn reset() -> Result<ResetResult> {
2412 let mut steps = Vec::new();
2413
2414 let managed_names = scan_managed_services().unwrap_or_default();
2419
2420 for svc in list_installed().unwrap_or_default() {
2427 if let Some(svc_name) = svc.exposure.tailscale_svc_name() {
2428 steps.push(Step::TailscaleDisable { svc_name });
2429 }
2430 }
2431
2432 let quadlet_path = quadlet_dir()?;
2434 let all_names: Vec<&str> = managed_names.iter().map(|s| s.as_str()).collect();
2435 let mut networks: Vec<String> = Vec::new();
2436 if quadlet_path.is_dir()
2437 && let Ok(entries) = std::fs::read_dir(&quadlet_path)
2438 {
2439 for entry in entries.flatten() {
2440 let file_name = entry.file_name();
2441 let name = file_name.to_string_lossy();
2442 let is_ryra_file = managed_names
2446 .iter()
2447 .any(|svc| quadlet_belongs_to(&name, svc, &all_names));
2448 if !is_ryra_file {
2449 continue;
2450 }
2451 if name.ends_with(".container") {
2452 let unit = name.trim_end_matches(".container").to_string();
2453 steps.push(Step::StopService { unit });
2454 }
2455 if name.ends_with(".network") {
2456 let net = name.trim_end_matches(".network").to_string();
2457 steps.push(Step::StopService {
2458 unit: format!("{net}-network"),
2459 });
2460 networks.push(net);
2461 }
2462 if name.ends_with(".volume") {
2463 let vol = name.trim_end_matches(".volume").to_string();
2464 steps.push(Step::StopService {
2471 unit: format!("{vol}-volume"),
2472 });
2473 }
2474 steps.push(Step::RemoveFile(entry.path()));
2475 }
2476 }
2477
2478 let user_unit_dir = systemd_user_dir()?;
2484 if let Ok(root) = service_data_root()
2485 && let Ok(entries) = std::fs::read_dir(&root)
2486 {
2487 for entry in entries.flatten() {
2488 let Some(name) = entry.file_name().to_str().map(str::to_string) else {
2489 continue;
2490 };
2491 if matches!(
2492 metadata::load_metadata(&name),
2493 Ok(Some(m)) if m.runtime == registry::service_def::Runtime::Native
2494 ) {
2495 steps.push(Step::StopService { unit: name.clone() });
2496 steps.push(Step::RemoveFile(
2497 user_unit_dir.join(format!("{name}.service")),
2498 ));
2499 }
2500 }
2501 }
2502
2503 steps.push(Step::DaemonReload);
2505
2506 for net in networks {
2509 steps.push(Step::RemoveNetwork { name: net });
2510 }
2511
2512 let mut seen_volumes = std::collections::BTreeSet::new();
2518 for svc in data::enumerate_all().unwrap_or_default() {
2519 for vol in svc.volumes {
2520 if seen_volumes.insert(vol.name.clone()) {
2521 steps.push(Step::RemoveVolume { name: vol.name });
2522 }
2523 }
2524 }
2525
2526 let data_root = service_data_root()?;
2532 if data_root.exists() {
2533 steps.push(Step::RemoveDir(data_root));
2534 }
2535
2536 Ok(ResetResult { steps })
2537}
2538
2539pub fn finalize_reset() -> Result<()> {
2541 let paths = ConfigPaths::resolve()?;
2542 if paths.config_dir.exists() {
2543 std::fs::remove_dir_all(&paths.config_dir).map_err(|source| Error::FileWrite {
2544 path: paths.config_dir,
2545 source,
2546 })?;
2547 }
2548 Ok(())
2549}
2550
2551pub fn status() -> config::status::RyraStatus {
2557 let paths = match ConfigPaths::resolve() {
2558 Ok(p) => p,
2559 Err(_) => return config::status::RyraStatus::NotInitialized,
2560 };
2561
2562 let has_quadlets = scan_managed_services()
2563 .map(|n| !n.is_empty())
2564 .unwrap_or(false);
2565
2566 let config = match config::load_config(&paths.config_file) {
2567 Ok(c) => c,
2568 Err(Error::ConfigNotFound(_)) if has_quadlets => config::schema::Config::default(),
2569 Err(Error::ConfigNotFound(_)) => return config::status::RyraStatus::NotInitialized,
2570 Err(e) => return config::status::RyraStatus::Error(e.to_string()),
2571 };
2572
2573 config::status::RyraStatus::Initialized(config::status::StatusInfo::from_config(
2574 paths.config_file,
2575 &config,
2576 ))
2577}
2578
2579pub fn is_service_installed(name: &str) -> bool {
2587 let Ok(Some(meta)) = metadata::load_metadata(name) else {
2591 return false;
2592 };
2593 match meta.runtime {
2594 registry::service_def::Runtime::Native => systemd_user_dir()
2597 .map(|d| {
2598 d.join(format!("{name}.service")).exists()
2599 || d.join(format!("{name}-blue.service")).exists()
2600 || d.join(format!("{name}-green.service")).exists()
2601 })
2602 .unwrap_or(false),
2603 registry::service_def::Runtime::Podman => scan_managed_services()
2604 .map(|names| names.iter().any(|n| n == name))
2605 .unwrap_or(false),
2606 }
2607}
2608
2609pub fn scan_managed_services() -> Result<Vec<String>> {
2622 let dir = match quadlet_dir() {
2623 Ok(d) => d,
2624 Err(_) => return Ok(Vec::new()),
2625 };
2626 let entries = match std::fs::read_dir(&dir) {
2627 Ok(e) => e,
2628 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
2629 Err(source) => return Err(Error::FileRead { path: dir, source }),
2630 };
2631 let mut names: Vec<String> = Vec::new();
2632 for entry in entries.flatten() {
2633 let path = entry.path();
2634 if path.extension().and_then(|e| e.to_str()) != Some("container") {
2635 continue;
2636 }
2637 let Ok(content) = std::fs::read_to_string(&path) else {
2638 continue;
2639 };
2640 for line in content.lines().take(16) {
2641 if let Some(rest) = line.trim().strip_prefix("# Service-Source: registry/")
2642 && !rest.is_empty()
2643 && !names.iter().any(|n| n == rest)
2644 {
2645 names.push(rest.to_string());
2646 break;
2647 }
2648 }
2649 }
2650 names.sort();
2651 Ok(names)
2652}
2653
2654fn build_installed_from_metadata(service_name: &str) -> Option<InstalledService> {
2660 let meta = load_metadata(service_name).ok().flatten()?;
2661
2662 let exposure = match meta.url.as_deref() {
2664 None => Exposure::Loopback,
2665 Some(u) => Exposure::from_url(u),
2666 };
2667
2668 let auth_kind = meta.auth.clone();
2669
2670 let ports = service_home(service_name)
2676 .ok()
2677 .and_then(|home| std::fs::read_to_string(home.join(".env")).ok())
2678 .map(|env| {
2679 env.lines()
2680 .filter_map(|l| {
2681 let l = l.trim();
2682 if l.is_empty() || l.starts_with('#') {
2683 return None;
2684 }
2685 let (key, val) = l.split_once('=')?;
2686 let name = key.strip_prefix("SERVICE_PORT_")?.to_lowercase();
2687 let port = val
2688 .trim_matches(|c: char| c == '"' || c == '\'')
2689 .parse::<u16>()
2690 .ok()?;
2691 Some((name, port))
2692 })
2693 .collect::<std::collections::BTreeMap<String, u16>>()
2694 })
2695 .unwrap_or_default();
2696
2697 Some(InstalledService {
2698 name: service_name.to_string(),
2699 version: "0.1.0".to_string(),
2700 repo: meta.registry,
2701 ports,
2702 auth_kind,
2703 exposure,
2704 provides: meta.provides,
2705 installed: true,
2706 })
2707}
2708
2709pub fn list_installed() -> Result<Vec<InstalledService>> {
2716 let mut names: std::collections::BTreeSet<String> = scan_managed_services()
2717 .unwrap_or_default()
2718 .into_iter()
2719 .collect();
2720 if let Ok(root) = service_data_root()
2724 && let Ok(entries) = std::fs::read_dir(&root)
2725 {
2726 for entry in entries.flatten() {
2727 if let Some(name) = entry.file_name().to_str()
2728 && !names.contains(name)
2729 && is_service_installed(name)
2730 {
2731 names.insert(name.to_string());
2732 }
2733 }
2734 }
2735 let out: Vec<InstalledService> = names
2736 .iter()
2737 .filter_map(|n| build_installed_from_metadata(n))
2738 .collect();
2739 Ok(out)
2740}
2741
2742pub fn search_services(repo_dir: &Path, query: Option<&str>) -> Result<Vec<SearchResult>> {
2744 let available = registry::list_available(repo_dir)?;
2745
2746 let results = available
2747 .into_iter()
2748 .filter(|reg_svc| match query {
2749 None => true,
2750 Some(q) => {
2751 let q = q.to_lowercase();
2752 reg_svc.def.service.name.to_lowercase().contains(&q)
2753 || reg_svc.def.service.description.to_lowercase().contains(&q)
2754 }
2755 })
2756 .map(|reg_svc| {
2757 let name = ®_svc.def.service.name;
2758 let installed = is_service_installed(name);
2759 let mut supports = Vec::new();
2760 for kind in ®_svc.def.integrations.auth {
2761 supports.push(kind.to_string());
2762 }
2763 if reg_svc.def.integrations.smtp {
2764 supports.push("smtp".to_string());
2765 }
2766 let recommended_ram_mb = reg_svc
2767 .def
2768 .requirements
2769 .as_ref()
2770 .and_then(|r| r.ram.recommended);
2771 SearchResult {
2772 name: name.clone(),
2773 description: reg_svc.def.service.description,
2774 installed,
2775 supports,
2776 recommended_ram_mb,
2777 }
2778 })
2779 .collect();
2780
2781 Ok(results)
2782}
2783
2784pub struct SearchResult {
2785 pub name: String,
2786 pub description: String,
2787 pub installed: bool,
2788 pub supports: Vec<String>,
2790 pub recommended_ram_mb: Option<u64>,
2793}
2794
2795pub async fn service_tests(service_name: &str) -> Result<ServiceTestInfo> {
2797 let installed = build_installed_from_metadata(service_name)
2798 .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
2799
2800 let service_ref = service_ref_from_installed(&installed);
2801 let repo_dir = resolve_registry_dir(&service_ref).await?;
2802
2803 let test_toml_path = repo_dir.join(service_name).join("test.toml");
2804 let env_file = service_home(service_name)?.join(".env");
2805
2806 if !test_toml_path.exists() {
2807 return Ok(ServiceTestInfo {
2808 service_name: service_name.to_string(),
2809 registry_name: service_ref.registry_name().to_string(),
2810 tests: vec![],
2811 env_file,
2812 });
2813 }
2814
2815 let content = std::fs::read_to_string(&test_toml_path).map_err(|source| Error::FileRead {
2816 path: test_toml_path.clone(),
2817 source,
2818 })?;
2819
2820 #[derive(serde::Deserialize)]
2821 struct TestFile {
2822 #[serde(default)]
2823 tests: Vec<registry::test_def::TestDef>,
2824 }
2825
2826 let parsed: TestFile = toml::from_str(&content).map_err(|source| Error::TomlParse {
2827 path: test_toml_path,
2828 source,
2829 })?;
2830
2831 Ok(ServiceTestInfo {
2832 service_name: service_name.to_string(),
2833 registry_name: service_ref.registry_name().to_string(),
2834 tests: parsed.tests,
2835 env_file,
2836 })
2837}
2838
2839pub struct ServiceTestInfo {
2840 pub service_name: String,
2841 pub registry_name: String,
2842 pub tests: Vec<registry::test_def::TestDef>,
2843 pub env_file: PathBuf,
2844}
2845
2846pub fn service_info(repo_dir: &Path, service_name: &str) -> Result<ServiceDetail> {
2848 let reg_service = registry::find_service(repo_dir, service_name)?;
2849 let def = ®_service.def;
2850
2851 Ok(ServiceDetail {
2852 name: def.service.name.clone(),
2853 description: def.service.description.clone(),
2854 url: def.service.url.clone(),
2855 ports: def
2856 .ports
2857 .iter()
2858 .map(|p| (p.container_port, p.protocol.clone(), p.name.clone()))
2859 .collect(),
2860 env_vars: def
2861 .env
2862 .iter()
2863 .map(|e| (e.name.clone(), e.prompt.clone()))
2864 .collect(),
2865 })
2866}
2867
2868pub struct ServiceDetail {
2869 pub name: String,
2870 pub description: String,
2871 pub url: Option<String>,
2872 pub ports: Vec<(u16, registry::service_def::PortProtocol, String)>,
2873 pub env_vars: Vec<(String, Option<String>)>,
2874}
2875
2876#[cfg(test)]
2877mod tests {
2878 use super::*;
2879
2880 fn write_demo_registry(tmp: &std::path::Path, deploy_line: &str) {
2885 let svc_dir = tmp.join("demo");
2886 std::fs::create_dir_all(svc_dir.join("quadlets")).unwrap();
2887 std::fs::write(
2888 svc_dir.join("service.toml"),
2889 format!(
2890 "[service]\n\
2891 name = \"demo\"\n\
2892 description = \"demo\"\n\
2893 runtime = \"podman\"\n\
2894 {deploy_line}\n\
2895 \n\
2896 [[ports]]\n\
2897 name = \"http\"\n\
2898 container_port = 8080\n"
2899 ),
2900 )
2901 .unwrap();
2902 std::fs::write(
2903 svc_dir.join("quadlets").join("demo.container"),
2904 "[Container]\n\
2905 Image=docker.io/traefik/whoami:latest\n\
2906 ContainerName=demo\n\
2907 PublishPort=${SERVICE_PORT_HTTP}:8080\n\
2908 EnvironmentFile=%h/.local/share/services/demo/.env\n\
2909 \n\
2910 [Service]\n\
2911 EnvironmentFile=%h/.local/share/services/demo/.env\n\
2912 \n\
2913 [Install]\n\
2914 WantedBy=default.target\n",
2915 )
2916 .unwrap();
2917 }
2918
2919 fn write_native_registry(tmp: &std::path::Path) {
2922 let svc_dir = tmp.join("napp");
2923 std::fs::create_dir_all(&svc_dir).unwrap();
2924 std::fs::write(
2925 svc_dir.join("service.toml"),
2926 "[service]\n\
2927 name = \"napp\"\n\
2928 description = \"native demo\"\n\
2929 runtime = \"native\"\n\
2930 run = \"python -m app\"\n\
2931 build = \"pip install -r requirements.txt\"\n\
2932 deploy = \"blue-green\"\n\
2933 health_check = \"/healthz\"\n\
2934 \n\
2935 [[ports]]\n\
2936 name = \"http\"\n\
2937 container_port = 8080\n",
2938 )
2939 .unwrap();
2940 std::fs::write(svc_dir.join("app.py"), "print('hi')\n").unwrap();
2942 }
2943
2944 fn plan_demo(tmp: &std::path::Path) -> AddResult {
2945 plan_service(tmp, "demo")
2946 }
2947
2948 fn plan_service(tmp: &std::path::Path, name: &'static str) -> AddResult {
2949 plan_service_exposed(tmp, name, exposure::Exposure::Loopback)
2950 }
2951
2952 fn plan_service_exposed(
2953 tmp: &std::path::Path,
2954 name: &'static str,
2955 exposure: exposure::Exposure,
2956 ) -> AddResult {
2957 let empty_map = std::collections::BTreeMap::new();
2958 let empty_ports: std::collections::BTreeMap<String, u16> =
2959 std::collections::BTreeMap::new();
2960 let empty_set = std::collections::BTreeSet::new();
2961 let port_in_use = |_p: u16| false;
2962 add_service(AddServiceParams {
2963 service_name: name,
2964 exposure: &exposure,
2965 auth: AuthChoice::None,
2966 enable_smtp: false,
2967 enable_backup: false,
2968 env_overrides: &empty_map,
2969 enabled_groups: &empty_set,
2970 selected_choices: &empty_map,
2971 registry_name: "test",
2972 repo_dir: tmp,
2973 pre_built_ctx: None,
2974 port_in_use: &port_in_use,
2975 acme_mode: None,
2976 mode: PlanMode::Add,
2977 port_overrides: &empty_ports,
2978 })
2979 .expect("plan add")
2980 }
2981
2982 #[test]
2986 fn blue_green_podman_add_emits_two_slots_and_starts_blue() {
2987 let tmp = tempfile::tempdir().unwrap();
2988 write_demo_registry(
2989 tmp.path(),
2990 "deploy = \"blue-green\"\nhealth_check = \"/healthz\"",
2991 );
2992 let result = plan_demo(tmp.path());
2993
2994 let written: Vec<String> = result
2997 .steps
2998 .iter()
2999 .filter_map(|s| match s {
3000 Step::WriteFile(f) => f
3001 .path
3002 .file_name()
3003 .and_then(|n| n.to_str())
3004 .map(String::from),
3005 _ => None,
3006 })
3007 .collect();
3008 assert!(
3009 written.iter().any(|n| n == "demo-blue.container"),
3010 "got {written:?}"
3011 );
3012 assert!(
3013 written.iter().any(|n| n == "demo-green.container"),
3014 "got {written:?}"
3015 );
3016 assert!(
3017 !written.iter().any(|n| n == "demo.container"),
3018 "bare slot leaked: {written:?}"
3019 );
3020
3021 let blue = result
3023 .steps
3024 .iter()
3025 .find_map(|s| match s {
3026 Step::WriteFile(f) if f.path.ends_with("demo-blue.container") => Some(&f.content),
3027 _ => None,
3028 })
3029 .unwrap();
3030 assert!(blue.contains("ContainerName=demo-blue"));
3031 assert!(blue.contains("${SERVICE_PORT_HTTP_BLUE}"));
3032
3033 let started: Vec<&str> = result
3035 .steps
3036 .iter()
3037 .filter_map(|s| match s {
3038 Step::StartService { unit } => Some(unit.as_str()),
3039 _ => None,
3040 })
3041 .collect();
3042 assert!(started.contains(&"demo-blue"), "started: {started:?}");
3043 assert!(!started.contains(&"demo"), "bare unit started: {started:?}");
3044
3045 let env = result
3047 .steps
3048 .iter()
3049 .find_map(|s| match s {
3050 Step::WriteFile(f) if f.path.file_name() == Some(std::ffi::OsStr::new(".env")) => {
3051 Some(&f.content)
3052 }
3053 _ => None,
3054 })
3055 .unwrap();
3056 assert!(env.contains("SERVICE_PORT_HTTP_BLUE="), "env: {env}");
3057 assert!(env.contains("SERVICE_PORT_HTTP_GREEN="), "env: {env}");
3058 }
3059
3060 #[test]
3064 fn blue_green_native_add_syncs_builds_and_starts_blue() {
3065 let tmp = tempfile::tempdir().unwrap();
3066 write_native_registry(tmp.path());
3067 let result = plan_service(tmp.path(), "napp");
3068
3069 let syncs: Vec<String> = result
3071 .steps
3072 .iter()
3073 .filter_map(|s| match s {
3074 Step::SyncDir { dst, .. } => Some(dst.to_string_lossy().into_owned()),
3075 _ => None,
3076 })
3077 .collect();
3078 assert!(
3079 syncs.iter().any(|d| d.ends_with("colors/blue")),
3080 "syncs: {syncs:?}"
3081 );
3082 assert!(
3083 syncs.iter().any(|d| d.ends_with("colors/green")),
3084 "syncs: {syncs:?}"
3085 );
3086 let builds: Vec<String> = result
3087 .steps
3088 .iter()
3089 .filter_map(|s| match s {
3090 Step::Build { dir, .. } => Some(dir.to_string_lossy().into_owned()),
3091 _ => None,
3092 })
3093 .collect();
3094 assert!(
3095 builds.iter().any(|d| d.ends_with("colors/blue")),
3096 "builds: {builds:?}"
3097 );
3098 assert!(
3099 builds.iter().any(|d| d.ends_with("colors/green")),
3100 "builds: {builds:?}"
3101 );
3102
3103 let green_unit = result
3106 .steps
3107 .iter()
3108 .find_map(|s| match s {
3109 Step::WriteFile(f) if f.path.ends_with("napp-green.service") => Some(&f.content),
3110 _ => None,
3111 })
3112 .expect("green unit");
3113 assert!(green_unit.contains("WorkingDirectory="));
3114 assert!(green_unit.contains("colors/green"));
3115 assert!(green_unit.contains("Environment=SERVICE_PORT_HTTP="));
3116 assert!(green_unit.contains("ExecStart=/bin/sh -c 'exec python -m app'"));
3117
3118 let started: Vec<&str> = result
3120 .steps
3121 .iter()
3122 .filter_map(|s| match s {
3123 Step::StartService { unit } => Some(unit.as_str()),
3124 _ => None,
3125 })
3126 .collect();
3127 assert_eq!(started, vec!["napp-blue"], "started: {started:?}");
3128
3129 let env = result
3131 .steps
3132 .iter()
3133 .find_map(|s| match s {
3134 Step::WriteFile(f) if f.path.file_name() == Some(std::ffi::OsStr::new(".env")) => {
3135 Some(&f.content)
3136 }
3137 _ => None,
3138 })
3139 .unwrap();
3140 assert!(env.contains("SERVICE_PORT_HTTP_BLUE="));
3141 assert!(env.contains("SERVICE_PORT_HTTP_GREEN="));
3142 }
3143
3144 #[test]
3150 fn blue_green_native_add_with_url_warns_when_no_caddy() {
3151 let tmp = tempfile::tempdir().unwrap();
3152 write_native_registry(tmp.path());
3153 let result = plan_service_exposed(
3154 tmp.path(),
3155 "napp",
3156 exposure::Exposure::Public {
3157 url: "https://napp.example.com".into(),
3158 },
3159 );
3160 assert!(
3161 result
3162 .warnings
3163 .iter()
3164 .any(|w| matches!(w, Warning::UrlWithoutReverseProxy { .. })),
3165 "native + url + no caddy should warn UrlWithoutReverseProxy"
3166 );
3167 }
3168
3169 #[test]
3172 fn restart_podman_add_is_unchanged() {
3173 let tmp = tempfile::tempdir().unwrap();
3174 write_demo_registry(tmp.path(), "");
3175 let result = plan_demo(tmp.path());
3176 let written: Vec<String> = result
3177 .steps
3178 .iter()
3179 .filter_map(|s| match s {
3180 Step::WriteFile(f) => f
3181 .path
3182 .file_name()
3183 .and_then(|n| n.to_str())
3184 .map(String::from),
3185 _ => None,
3186 })
3187 .collect();
3188 assert!(
3189 written.iter().any(|n| n == "demo.container"),
3190 "got {written:?}"
3191 );
3192 assert!(
3193 !written.iter().any(|n| n.contains("-blue")),
3194 "got {written:?}"
3195 );
3196 let started: Vec<&str> = result
3197 .steps
3198 .iter()
3199 .filter_map(|s| match s {
3200 Step::StartService { unit } => Some(unit.as_str()),
3201 _ => None,
3202 })
3203 .collect();
3204 assert!(started.contains(&"demo"));
3205 }
3206
3207 #[test]
3208 fn static_template_filter_excludes_secrets_and_credentials() {
3209 assert!(is_static_template("3306"));
3211 assert!(is_static_template("mariadb"));
3212 assert!(is_static_template("{{service.port}}"));
3214 assert!(is_static_template("{{service.url}}"));
3215 assert!(is_static_template("{{auth.url}}"));
3216 assert!(is_static_template("{{auth.issuer}}"));
3217 assert!(is_static_template("{{auth.provider}}"));
3218 assert!(is_static_template("{{auth.internal_url}}"));
3219 assert!(is_static_template("{{smtp.host}}"));
3220 assert!(is_static_template("{{smtp.port}}"));
3221 assert!(is_static_template("{{smtp.from}}"));
3222 assert!(is_static_template("{{service.url}}/oauth/callback"));
3224
3225 assert!(!is_static_template("{{secret.admin_password}}"));
3227 assert!(!is_static_template("{{secret.jwt_key}}"));
3228 assert!(!is_static_template("{{auth.client_id}}"));
3230 assert!(!is_static_template("{{auth.client_secret}}"));
3231 assert!(!is_static_template("{{smtp.username}}"));
3233 assert!(!is_static_template("{{smtp.password}}"));
3234 assert!(!is_static_template(
3236 "redis://:{{secret.redis_pw}}@host:6379"
3237 ));
3238 }
3239
3240 #[test]
3241 fn tailscale_url_matches() {
3242 assert!(is_tailscale_url("http://debian.cobbler-tuna.ts.net"));
3243 assert!(is_tailscale_url("http://debian.cobbler-tuna.ts.net:10001/"));
3244 assert!(is_tailscale_url("https://foo.example-net.ts.net"));
3245 assert!(is_tailscale_url("http://HOST.COBBLER-TUNA.TS.NET"));
3246 }
3247
3248 #[test]
3249 fn tailscale_url_rejects() {
3250 assert!(!is_tailscale_url("https://nextcloud.internal:8443"));
3251 assert!(!is_tailscale_url("https://example.com"));
3252 assert!(!is_tailscale_url("http://127.0.0.1:10001"));
3253 assert!(!is_tailscale_url("https://ts.net"));
3255 assert!(!is_tailscale_url("https://evil-ts.net.example.com"));
3256 assert!(!is_tailscale_url("not a url"));
3257 }
3258
3259 #[test]
3260 fn public_url_accepts_public_domains() {
3261 assert!(is_public_url("https://seafile.ryra.no"));
3262 assert!(is_public_url("https://example.com"));
3263 assert!(is_public_url("https://docs.ryra.no:8443"));
3264 }
3265
3266 #[test]
3267 fn public_url_rejects_lan_and_tailnet() {
3268 assert!(!is_public_url("https://nextcloud.internal:8443"));
3269 assert!(!is_public_url("https://service.localhost"));
3270 assert!(!is_public_url("https://something.local"));
3271 assert!(!is_public_url("https://localhost:8080"));
3272 assert!(!is_public_url("https://debian.cobbler-tuna.ts.net"));
3273 assert!(!is_public_url("http://127.0.0.1:10001"));
3274 assert!(!is_public_url("http://192.168.1.10"));
3275 assert!(!is_public_url("http://[::1]"));
3276 assert!(!is_public_url("not a url"));
3277 }
3278
3279 #[test]
3284 fn networks_empty_when_no_auth() {
3285 let nets = resolve_extra_networks(
3286 "whoami", false, false, false, false, false, false, None, false,
3287 );
3288 assert!(nets.is_empty());
3289 }
3290
3291 #[test]
3292 fn networks_empty_when_auth_but_no_authelia() {
3293 let nets = resolve_extra_networks(
3294 "forgejo", true, false, false, false, false, false, None, false,
3295 );
3296 assert!(nets.is_empty());
3297 }
3298
3299 #[test]
3300 fn networks_authelia_when_auth_enabled() {
3301 let nets = resolve_extra_networks(
3302 "forgejo", true, true, false, false, false, false, None, false,
3303 );
3304 assert_eq!(nets, vec!["authelia"]);
3305 }
3306
3307 #[test]
3308 fn networks_auth_with_caddy_includes_both() {
3309 let nets = resolve_extra_networks(
3310 "forgejo", true, true, true, false, false, false, None, false,
3311 );
3312 assert!(nets.contains(&"authelia".to_string()));
3313 assert!(nets.contains(&"caddy".to_string()));
3314 }
3315
3316 #[test]
3317 fn networks_authelia_excluded_for_authelia_itself() {
3318 let nets = resolve_extra_networks(
3319 "authelia", true, true, false, false, false, false, None, false,
3320 );
3321 assert!(nets.is_empty());
3322 }
3323
3324 #[test]
3325 fn networks_smtp_joins_inbucket_without_caddy() {
3326 let nets = resolve_extra_networks(
3328 "forgejo", false, false, false, true, false, true, None, false,
3329 );
3330 assert_eq!(nets, vec!["inbucket"]);
3331 }
3332
3333 #[test]
3334 fn networks_smtp_skips_inbucket_when_it_is_self() {
3335 let nets = resolve_extra_networks(
3336 "inbucket", false, false, false, true, false, true, None, false,
3337 );
3338 assert!(!nets.contains(&"inbucket".to_string()));
3339 }
3340
3341 #[test]
3342 fn networks_smtp_skips_inbucket_when_not_installed() {
3343 let nets = resolve_extra_networks(
3344 "forgejo", false, false, false, false, false, true, None, false,
3345 );
3346 assert!(!nets.contains(&"inbucket".to_string()));
3347 }
3348
3349 #[test]
3350 fn networks_metrics_consumer_joins_store() {
3351 let nets = resolve_extra_networks(
3352 "grafana",
3353 false,
3354 false,
3355 false,
3356 false,
3357 false,
3358 false,
3359 Some("prometheus"),
3360 true,
3361 );
3362 assert_eq!(nets, vec!["prometheus".to_string()]);
3363 }
3364
3365 #[test]
3366 fn networks_metrics_store_skips_itself() {
3367 let nets = resolve_extra_networks(
3368 "prometheus",
3369 false,
3370 false,
3371 false,
3372 false,
3373 false,
3374 false,
3375 Some("prometheus"),
3376 true,
3377 );
3378 assert!(nets.is_empty());
3379 }
3380
3381 #[test]
3382 fn networks_metrics_indifferent_service_skips_store() {
3383 let nets = resolve_extra_networks(
3384 "vaultwarden",
3385 false,
3386 false,
3387 false,
3388 false,
3389 false,
3390 false,
3391 Some("prometheus"),
3392 false,
3393 );
3394 assert!(nets.is_empty());
3395 }
3396
3397 #[test]
3398 fn quadlet_belongs_to_exact_match() {
3399 let all = &["foo", "foo-bar"];
3400 assert!(quadlet_belongs_to("foo.container", "foo", all));
3401 assert!(quadlet_belongs_to("foo.network", "foo", all));
3402 }
3403
3404 #[test]
3405 fn quadlet_belongs_to_sidecar() {
3406 let all = &["foo"];
3408 assert!(quadlet_belongs_to("foo-db.volume", "foo", all));
3409 }
3410
3411 #[test]
3412 fn quadlet_belongs_to_rejects_prefix_collision() {
3413 let all = &["foo", "foo-bar"];
3414 assert!(!quadlet_belongs_to("foo-bar.container", "foo", all));
3415 assert!(!quadlet_belongs_to("foo-bar-db.volume", "foo", all));
3416 }
3417
3418 #[test]
3419 fn quadlet_belongs_to_hyphenated_service() {
3420 let all = &["foo", "foo-bar"];
3421 assert!(quadlet_belongs_to("foo-bar.container", "foo-bar", all));
3422 assert!(quadlet_belongs_to("foo-bar-db.volume", "foo-bar", all));
3423 assert!(!quadlet_belongs_to("foo.container", "foo-bar", all));
3424 }
3425}