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::StartService { unit: app_unit });
1633
1634 steps.extend(caddy_steps);
1637
1638 Ok(AddResult {
1639 steps,
1640 warnings,
1641 repo_url: registry_name.to_string(),
1642 allocated_ports,
1643 generated_secrets,
1644 env_content,
1645 url: url.map(|u| u.to_string()),
1646 tracked_envs,
1647 })
1648}
1649
1650fn native_unit(home_dir: &Path, source_dir: &Path, run: &str, description: &str) -> String {
1655 let home = home_dir.display();
1656 let source = source_dir.display();
1657 format!(
1666 "[Unit]\n\
1667 Description={description}\n\
1668 After=network.target\n\
1669 \n\
1670 [Service]\n\
1671 Type=simple\n\
1672 WorkingDirectory={source}\n\
1673 EnvironmentFile={home}/.env\n\
1674 Environment=SERVICE_HOME={home}\n\
1675 Environment=PATH=%h/.local/bin:%h/.cargo/bin:%h/.bun/bin:%h/.deno/bin:%h/go/bin:/usr/local/bin:/usr/bin:/bin\n\
1676 ExecStart=/bin/sh -c 'exec {run}'\n\
1677 Restart=always\n\
1678 RestartSec=5\n\
1679 \n\
1680 [Install]\n\
1681 WantedBy=default.target\n",
1682 )
1683}
1684
1685pub fn quadlet_belongs_to(filename: &str, service_name: &str, all_service_names: &[&str]) -> bool {
1694 if !filename.starts_with(service_name) {
1695 return false;
1696 }
1697 let rest = &filename[service_name.len()..];
1698 if rest.starts_with('.') {
1699 return true;
1700 }
1701 if !rest.starts_with('-') {
1702 return false;
1703 }
1704 !all_service_names.iter().any(|&other| {
1708 other.len() > service_name.len()
1709 && other.starts_with(service_name)
1710 && filename.starts_with(other)
1711 && filename[other.len()..].starts_with(['.', '-'])
1712 })
1713}
1714
1715#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
1717#[serde(rename_all = "snake_case")]
1718pub enum RemoveMode {
1719 #[default]
1720 Preserve,
1725 Purge,
1727}
1728
1729pub fn remove_service(service_name: &str, mode: RemoveMode) -> Result<RemoveResult> {
1731 let installed_owned = build_installed_from_metadata(service_name)
1734 .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
1735 let installed = &installed_owned;
1736
1737 if let Ok(Some(meta)) = metadata::load_metadata(service_name)
1742 && meta.runtime == registry::service_def::Runtime::Native
1743 {
1744 let url = installed.exposure.url().map(|s| s.to_string());
1745 return remove_native_service(service_name, mode, url);
1746 }
1747
1748 let quadlet_path = quadlet_dir()?;
1751 let mut steps = Vec::new();
1752 let mut volume_names = Vec::new();
1753 let mut networks: Vec<String> = Vec::new();
1754 let mut has_named_volumes = false;
1755 let name_pool = scan_managed_services().unwrap_or_default();
1759 let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
1760
1761 if let Some(svc_name) = installed.exposure.tailscale_svc_name() {
1773 steps.push(Step::TailscaleDisable { svc_name });
1774 }
1775
1776 if quadlet_path.is_dir()
1777 && let Ok(entries) = std::fs::read_dir(&quadlet_path)
1778 {
1779 for entry in entries.flatten() {
1780 let file_name = entry.file_name();
1781 let name = file_name.to_string_lossy();
1782 if !quadlet_belongs_to(&name, service_name, &all_names) {
1785 continue;
1786 }
1787 if name.ends_with(".container") {
1789 let unit = name.trim_end_matches(".container").to_string();
1790 steps.push(Step::StopService { unit });
1791 }
1792 if name.ends_with(".network") {
1793 let net = name.trim_end_matches(".network").to_string();
1796 steps.push(Step::StopService {
1797 unit: format!("{net}-network"),
1798 });
1799 networks.push(net);
1800 }
1801 if name.ends_with(".volume") {
1802 has_named_volumes = true;
1803 if matches!(mode, RemoveMode::Purge) {
1804 let vol = name.trim_end_matches(".volume").to_string();
1805 volume_names.push(format!("systemd-{vol}"));
1807 }
1808 }
1809 steps.push(Step::RemoveFile(entry.path()));
1810 }
1811 }
1812
1813 let had_caddy_route = matches!(
1820 installed.exposure,
1821 Exposure::Internal { .. } | Exposure::Public { .. }
1822 );
1823 if !WellKnownService::Caddy.matches(service_name) && had_caddy_route {
1824 let caddyfile_path = caddy::caddyfile_path()?;
1825 if caddyfile_path.exists() {
1826 let existing =
1827 std::fs::read_to_string(&caddyfile_path).map_err(|source| Error::FileRead {
1828 path: caddyfile_path.clone(),
1829 source,
1830 })?;
1831 let updated = caddy::remove_route(&existing, service_name);
1832 if updated != existing {
1833 steps.push(Step::WriteFile(GeneratedFile {
1834 path: caddyfile_path,
1835 content: updated.clone(),
1836 }));
1837 if !updated.trim().is_empty() {
1840 steps.push(Step::ReloadCaddy);
1841 }
1842 }
1843 }
1844 }
1845
1846 if !WellKnownService::Authelia.matches(service_name)
1847 && matches!(
1848 installed.auth_kind,
1849 Some(registry::service_def::AuthKind::Oidc)
1850 )
1851 {
1852 steps.extend(authelia::unregister_oidc_client(service_name)?);
1853 }
1854
1855 let installed_all = list_installed().unwrap_or_default();
1861 for store in installed_all
1862 .iter()
1863 .filter(|s| installed_provides(s, Capability::MetricsStore))
1864 {
1865 if store.name != service_name
1866 && let Ok(target) = metrics_bridge::target_file_path(&store.name, service_name)
1867 && target.exists()
1868 {
1869 steps.push(Step::RemoveFile(target));
1870 }
1871 }
1872 if installed.provides.contains(&Capability::MetricsStore) {
1873 for dash in installed_all
1874 .iter()
1875 .filter(|s| installed_provides(s, Capability::MetricsDashboard))
1876 {
1877 if dash.name == service_name {
1878 continue;
1879 }
1880 if let Ok(ds) = metrics_bridge::datasource_file_path(&dash.name, service_name)
1881 && ds.exists()
1882 {
1883 steps.push(Step::RemoveFile(ds));
1884 steps.push(Step::RestartService {
1885 unit: dash.name.clone(),
1886 });
1887 }
1888 }
1889 }
1890
1891 steps.push(Step::DaemonReload);
1893
1894 for net in networks {
1902 steps.push(Step::RemoveNetwork { name: net });
1903 }
1904
1905 match mode {
1906 RemoveMode::Purge => {
1907 for vol_name in volume_names {
1909 steps.push(Step::RemoveVolume { name: vol_name });
1910 }
1911 steps.push(Step::RemoveDir(service_home(service_name)?));
1913 }
1914 RemoveMode::Preserve => {
1915 let home = service_home(service_name)?;
1919 let (data, ephemeral) = crate::data::classify::classify_home_dir(&home)?;
1920 for path in ephemeral {
1921 match std::fs::metadata(&path) {
1922 Ok(m) if m.is_dir() => steps.push(Step::RemoveDir(path)),
1923 Ok(_) => steps.push(Step::RemoveFile(path)),
1924 Err(_) => steps.push(Step::RemoveFile(path)),
1928 }
1929 }
1930 if data.is_empty() && !has_named_volumes && home.exists() {
1938 steps.push(Step::RemoveDir(home));
1939 }
1940 }
1941 }
1942
1943 let url = installed.exposure.url().map(|s| s.to_string());
1944
1945 Ok(RemoveResult {
1946 steps,
1947 service_name: service_name.to_string(),
1948 url,
1949 })
1950}
1951
1952fn remove_native_service(
1957 service_name: &str,
1958 mode: RemoveMode,
1959 url: Option<String>,
1960) -> Result<RemoveResult> {
1961 let home = service_home(service_name)?;
1962 let unit_dir = systemd_user_dir()?;
1966 let unit_names: Vec<String> = [
1967 format!("{service_name}.service"),
1968 format!("{service_name}-blue.service"),
1969 format!("{service_name}-green.service"),
1970 ]
1971 .into_iter()
1972 .filter(|u| unit_dir.join(u).exists())
1973 .collect();
1974 let mut steps = Vec::new();
1975
1976 let mut aux_container_files: Vec<String> = Vec::new();
1982 if let Ok(qdir) = quadlet_dir() {
1983 let names = scan_managed_services().unwrap_or_default();
1984 let all: Vec<&str> = names.iter().map(|s| s.as_str()).collect();
1985 if let Ok(entries) = std::fs::read_dir(&qdir) {
1986 for entry in entries.flatten() {
1987 let fname = entry.file_name().to_string_lossy().into_owned();
1988 if let Some(stem) = fname.strip_suffix(".container")
1989 && quadlet_belongs_to(&fname, service_name, &all)
1990 {
1991 steps.push(Step::StopService {
1992 unit: stem.to_string(),
1993 });
1994 steps.push(Step::RemoveFile(qdir.join(&fname)));
1995 aux_container_files.push(fname);
1996 }
1997 }
1998 }
1999 }
2000
2001 for unit_name in &unit_names {
2002 steps.push(Step::StopService {
2003 unit: unit_name.trim_end_matches(".service").to_string(),
2004 });
2005 steps.push(Step::RemoveFile(unit_dir.join(unit_name)));
2006 }
2007 steps.push(Step::DaemonReload);
2008
2009 match mode {
2010 RemoveMode::Purge => steps.push(Step::RemoveDir(home)),
2011 RemoveMode::Preserve => {
2012 let mut ephemeral: Vec<String> = vec!["bin".into(), ".env".into(), "colors".into()];
2017 ephemeral.extend(unit_names.iter().cloned());
2018 for child in &ephemeral {
2019 let p = home.join(child);
2020 match std::fs::metadata(&p) {
2021 Ok(m) if m.is_dir() => steps.push(Step::RemoveDir(p)),
2022 Ok(_) => steps.push(Step::RemoveFile(p)),
2023 Err(_) => {} }
2025 }
2026 for f in &aux_container_files {
2027 steps.push(Step::RemoveFile(home.join(f)));
2028 }
2029 }
2030 }
2031
2032 Ok(RemoveResult {
2033 steps,
2034 service_name: service_name.to_string(),
2035 url,
2036 })
2037}
2038
2039#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2041#[serde(rename_all = "snake_case")]
2042pub enum Lifecycle {
2043 Start,
2044 Stop,
2045}
2046
2047pub fn lifecycle_steps(service_name: &str, action: Lifecycle) -> Result<Vec<Step>> {
2056 build_installed_from_metadata(service_name)
2058 .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
2059
2060 if matches!(
2063 metadata::load_metadata(service_name),
2064 Ok(Some(m)) if m.runtime == registry::service_def::Runtime::Native
2065 ) {
2066 let unit = service_name.to_string();
2067 return Ok(vec![match action {
2068 Lifecycle::Start => Step::StartService { unit },
2069 Lifecycle::Stop => Step::StopService { unit },
2070 }]);
2071 }
2072
2073 let mut units = service_container_units(service_name)?;
2074 match action {
2075 Lifecycle::Stop => units.sort_by_key(|u| u != service_name),
2077 Lifecycle::Start => units.sort_by_key(|u| u == service_name),
2079 }
2080
2081 Ok(units
2082 .into_iter()
2083 .map(|unit| match action {
2084 Lifecycle::Start => Step::StartService { unit },
2085 Lifecycle::Stop => Step::StopService { unit },
2086 })
2087 .collect())
2088}
2089
2090fn service_container_units(service_name: &str) -> Result<Vec<String>> {
2094 let quadlet_path = quadlet_dir()?;
2095 let name_pool = scan_managed_services().unwrap_or_default();
2096 let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
2097
2098 let mut units = Vec::new();
2099 if quadlet_path.is_dir()
2100 && let Ok(entries) = std::fs::read_dir(&quadlet_path)
2101 {
2102 for entry in entries.flatten() {
2103 let file_name = entry.file_name();
2104 let name = file_name.to_string_lossy();
2105 if !quadlet_belongs_to(&name, service_name, &all_names) {
2106 continue;
2107 }
2108 if name.ends_with(".container") {
2109 units.push(name.trim_end_matches(".container").to_string());
2110 }
2111 }
2112 }
2113 Ok(units)
2114}
2115
2116pub struct RecordPendingParams<'a> {
2118 pub service_name: &'a str,
2119 pub auth_kind: Option<registry::service_def::AuthKind>,
2120 pub registry_name: &'a str,
2121 pub allocated_ports: &'a [(String, u16)],
2122 pub repo_dir: &'a Path,
2123 pub exposure: &'a Exposure,
2130}
2131
2132pub fn record_pending(params: RecordPendingParams<'_>) -> Result<()> {
2139 let paths = ConfigPaths::resolve()?;
2140 paths.ensure_dirs()?;
2141 let mut config = config::load_or_default(&paths.config_file)?;
2142
2143 if WellKnownService::Authelia.matches(params.service_name) {
2148 config.auth = Some(authelia::auth_config(
2149 params.allocated_ports,
2150 params.exposure.url(),
2151 )?);
2152 config::save_config(&paths.config_file, &config)?;
2153 }
2154
2155 Ok(())
2156}
2157
2158pub fn finalize_remove(service_name: &str) -> Result<()> {
2165 let paths = ConfigPaths::resolve()?;
2166 let mut config = config::load_or_default(&paths.config_file)?;
2167
2168 if WellKnownService::Authelia.matches(service_name)
2169 && let Some(auth) = &config.auth
2170 && auth.provider_name() == "authelia"
2171 {
2172 config.auth = None;
2173 config::save_config(&paths.config_file, &config)?;
2174 }
2175
2176 Ok(())
2177}
2178
2179const SENSITIVE_TEMPLATE_REFS: &[&str] = &[
2198 "{{secret.",
2199 "{{auth.client_id",
2200 "{{auth.client_secret",
2201 "{{smtp.username",
2202 "{{smtp.password",
2203];
2204
2205fn is_static_template(value: &str) -> bool {
2206 !SENSITIVE_TEMPLATE_REFS.iter().any(|s| value.contains(s))
2207}
2208
2209fn collect_static_envs(
2225 service_def: ®istry::service_def::ServiceDef,
2226 ctx: &BTreeMap<String, String>,
2227 enabled_groups: &std::collections::BTreeSet<String>,
2228 selected_choices: &BTreeMap<String, String>,
2229) -> Result<Vec<plan::TrackedEnv>> {
2230 use registry::service_def::EnvKind;
2231 let mut out: Vec<plan::TrackedEnv> = Vec::new();
2232 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
2233 let push = |name: &str,
2234 value_template: &str,
2235 kind: EnvKind,
2236 prompt: Option<String>,
2237 out: &mut Vec<plan::TrackedEnv>,
2238 seen: &mut std::collections::HashSet<String>|
2239 -> Result<()> {
2240 if !is_static_template(value_template) {
2241 return Ok(());
2242 }
2243 if !seen.insert(name.to_string()) {
2244 return Ok(());
2245 }
2246 let value = generate::template::render(value_template, ctx)?;
2247 out.push(plan::TrackedEnv {
2248 key: name.to_string(),
2249 value,
2250 kind,
2251 prompt,
2252 });
2253 Ok(())
2254 };
2255 for env in &service_def.env {
2256 push(
2257 &env.name,
2258 &env.value,
2259 env.kind.clone(),
2260 env.prompt.clone(),
2261 &mut out,
2262 &mut seen,
2263 )?;
2264 }
2265 for group in &service_def.env_groups {
2266 if !enabled_groups.contains(&group.name) {
2267 continue;
2268 }
2269 for env in &group.env {
2270 push(
2271 &env.name,
2272 &env.value,
2273 env.kind.clone(),
2274 env.prompt.clone(),
2275 &mut out,
2276 &mut seen,
2277 )?;
2278 }
2279 }
2280 for choice in &service_def.choices {
2283 let selected = selected_choices
2284 .get(&choice.name)
2285 .unwrap_or(&choice.default);
2286 let Some(option) = choice.options.iter().find(|o| &o.name == selected) else {
2287 continue;
2288 };
2289 for env in &option.env {
2290 push(
2291 &env.name,
2292 &env.value,
2293 env.kind.clone(),
2294 env.prompt.clone(),
2295 &mut out,
2296 &mut seen,
2297 )?;
2298 }
2299 }
2300 if service_def.integrations.smtp && ctx.contains_key("smtp.host") {
2306 for (env_name, value_template) in &service_def.mappings.smtp {
2307 push(
2308 env_name,
2309 value_template,
2310 EnvKind::Default,
2311 None,
2312 &mut out,
2313 &mut seen,
2314 )?;
2315 }
2316 }
2317 if ctx.contains_key("auth.client_id") {
2318 for (env_name, value_template) in &service_def.mappings.auth {
2319 push(
2320 env_name,
2321 value_template,
2322 EnvKind::Default,
2323 None,
2324 &mut out,
2325 &mut seen,
2326 )?;
2327 }
2328 }
2329 Ok(out)
2330}
2331
2332pub fn orphan_purge_steps(svc: &data::ServiceData) -> Vec<Step> {
2333 let mut steps = Vec::new();
2334
2335 let mut had_quadlet = false;
2341 let mut networks: Vec<String> = Vec::new();
2342 if let Ok(qdir) = quadlet_dir()
2343 && qdir.is_dir()
2344 && let Ok(entries) = std::fs::read_dir(&qdir)
2345 {
2346 let name_pool = scan_managed_services().unwrap_or_default();
2347 let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
2348 for entry in entries.flatten() {
2349 let file_name = entry.file_name();
2350 let name = file_name.to_string_lossy();
2351 if !quadlet_belongs_to(&name, &svc.service, &all_names) {
2352 continue;
2353 }
2354 if name.ends_with(".container") {
2358 let unit = name.trim_end_matches(".container").to_string();
2359 steps.push(Step::StopService { unit });
2360 } else if name.ends_with(".network") {
2361 let net = name.trim_end_matches(".network").to_string();
2362 steps.push(Step::StopService {
2363 unit: format!("{net}-network"),
2364 });
2365 networks.push(net);
2366 } else if name.ends_with(".volume") {
2367 let unit = format!("{}-volume", name.trim_end_matches(".volume"));
2368 steps.push(Step::StopService { unit });
2369 }
2370 steps.push(Step::RemoveFile(entry.path()));
2371 had_quadlet = true;
2372 }
2373 }
2374 if had_quadlet {
2375 steps.push(Step::DaemonReload);
2376 }
2377 for net in networks {
2379 steps.push(Step::RemoveNetwork { name: net });
2380 }
2381
2382 for path in &svc.data_paths {
2383 if path.is_dir() {
2384 steps.push(Step::RemoveDir(path.clone()));
2385 } else {
2386 steps.push(Step::RemoveFile(path.clone()));
2387 }
2388 }
2389 if svc.home_dir.exists() {
2390 steps.push(Step::RemoveDir(svc.home_dir.clone()));
2391 }
2392 for v in &svc.volumes {
2393 steps.push(Step::RemoveVolume {
2394 name: v.name.clone(),
2395 });
2396 }
2397 steps
2398}
2399
2400pub fn reset() -> Result<ResetResult> {
2402 let mut steps = Vec::new();
2403
2404 let managed_names = scan_managed_services().unwrap_or_default();
2409
2410 for svc in list_installed().unwrap_or_default() {
2417 if let Some(svc_name) = svc.exposure.tailscale_svc_name() {
2418 steps.push(Step::TailscaleDisable { svc_name });
2419 }
2420 }
2421
2422 let quadlet_path = quadlet_dir()?;
2424 let all_names: Vec<&str> = managed_names.iter().map(|s| s.as_str()).collect();
2425 let mut networks: Vec<String> = Vec::new();
2426 if quadlet_path.is_dir()
2427 && let Ok(entries) = std::fs::read_dir(&quadlet_path)
2428 {
2429 for entry in entries.flatten() {
2430 let file_name = entry.file_name();
2431 let name = file_name.to_string_lossy();
2432 let is_ryra_file = managed_names
2436 .iter()
2437 .any(|svc| quadlet_belongs_to(&name, svc, &all_names));
2438 if !is_ryra_file {
2439 continue;
2440 }
2441 if name.ends_with(".container") {
2442 let unit = name.trim_end_matches(".container").to_string();
2443 steps.push(Step::StopService { unit });
2444 }
2445 if name.ends_with(".network") {
2446 let net = name.trim_end_matches(".network").to_string();
2447 steps.push(Step::StopService {
2448 unit: format!("{net}-network"),
2449 });
2450 networks.push(net);
2451 }
2452 if name.ends_with(".volume") {
2453 let vol = name.trim_end_matches(".volume").to_string();
2454 steps.push(Step::StopService {
2461 unit: format!("{vol}-volume"),
2462 });
2463 }
2464 steps.push(Step::RemoveFile(entry.path()));
2465 }
2466 }
2467
2468 let user_unit_dir = systemd_user_dir()?;
2474 if let Ok(root) = service_data_root()
2475 && let Ok(entries) = std::fs::read_dir(&root)
2476 {
2477 for entry in entries.flatten() {
2478 let Some(name) = entry.file_name().to_str().map(str::to_string) else {
2479 continue;
2480 };
2481 if matches!(
2482 metadata::load_metadata(&name),
2483 Ok(Some(m)) if m.runtime == registry::service_def::Runtime::Native
2484 ) {
2485 steps.push(Step::StopService { unit: name.clone() });
2486 steps.push(Step::RemoveFile(
2487 user_unit_dir.join(format!("{name}.service")),
2488 ));
2489 }
2490 }
2491 }
2492
2493 steps.push(Step::DaemonReload);
2495
2496 for net in networks {
2499 steps.push(Step::RemoveNetwork { name: net });
2500 }
2501
2502 let mut seen_volumes = std::collections::BTreeSet::new();
2508 for svc in data::enumerate_all().unwrap_or_default() {
2509 for vol in svc.volumes {
2510 if seen_volumes.insert(vol.name.clone()) {
2511 steps.push(Step::RemoveVolume { name: vol.name });
2512 }
2513 }
2514 }
2515
2516 let data_root = service_data_root()?;
2522 if data_root.exists() {
2523 steps.push(Step::RemoveDir(data_root));
2524 }
2525
2526 Ok(ResetResult { steps })
2527}
2528
2529pub fn finalize_reset() -> Result<()> {
2531 let paths = ConfigPaths::resolve()?;
2532 if paths.config_dir.exists() {
2533 std::fs::remove_dir_all(&paths.config_dir).map_err(|source| Error::FileWrite {
2534 path: paths.config_dir,
2535 source,
2536 })?;
2537 }
2538 Ok(())
2539}
2540
2541pub fn status() -> config::status::RyraStatus {
2547 let paths = match ConfigPaths::resolve() {
2548 Ok(p) => p,
2549 Err(_) => return config::status::RyraStatus::NotInitialized,
2550 };
2551
2552 let has_quadlets = scan_managed_services()
2553 .map(|n| !n.is_empty())
2554 .unwrap_or(false);
2555
2556 let config = match config::load_config(&paths.config_file) {
2557 Ok(c) => c,
2558 Err(Error::ConfigNotFound(_)) if has_quadlets => config::schema::Config::default(),
2559 Err(Error::ConfigNotFound(_)) => return config::status::RyraStatus::NotInitialized,
2560 Err(e) => return config::status::RyraStatus::Error(e.to_string()),
2561 };
2562
2563 config::status::RyraStatus::Initialized(config::status::StatusInfo::from_config(
2564 paths.config_file,
2565 &config,
2566 ))
2567}
2568
2569pub fn is_service_installed(name: &str) -> bool {
2577 let Ok(Some(meta)) = metadata::load_metadata(name) else {
2581 return false;
2582 };
2583 match meta.runtime {
2584 registry::service_def::Runtime::Native => systemd_user_dir()
2587 .map(|d| {
2588 d.join(format!("{name}.service")).exists()
2589 || d.join(format!("{name}-blue.service")).exists()
2590 || d.join(format!("{name}-green.service")).exists()
2591 })
2592 .unwrap_or(false),
2593 registry::service_def::Runtime::Podman => scan_managed_services()
2594 .map(|names| names.iter().any(|n| n == name))
2595 .unwrap_or(false),
2596 }
2597}
2598
2599pub fn scan_managed_services() -> Result<Vec<String>> {
2612 let dir = match quadlet_dir() {
2613 Ok(d) => d,
2614 Err(_) => return Ok(Vec::new()),
2615 };
2616 let entries = match std::fs::read_dir(&dir) {
2617 Ok(e) => e,
2618 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
2619 Err(source) => return Err(Error::FileRead { path: dir, source }),
2620 };
2621 let mut names: Vec<String> = Vec::new();
2622 for entry in entries.flatten() {
2623 let path = entry.path();
2624 if path.extension().and_then(|e| e.to_str()) != Some("container") {
2625 continue;
2626 }
2627 let Ok(content) = std::fs::read_to_string(&path) else {
2628 continue;
2629 };
2630 for line in content.lines().take(16) {
2631 if let Some(rest) = line.trim().strip_prefix("# Service-Source: registry/")
2632 && !rest.is_empty()
2633 && !names.iter().any(|n| n == rest)
2634 {
2635 names.push(rest.to_string());
2636 break;
2637 }
2638 }
2639 }
2640 names.sort();
2641 Ok(names)
2642}
2643
2644fn build_installed_from_metadata(service_name: &str) -> Option<InstalledService> {
2650 let meta = load_metadata(service_name).ok().flatten()?;
2651
2652 let exposure = match meta.url.as_deref() {
2654 None => Exposure::Loopback,
2655 Some(u) => Exposure::from_url(u),
2656 };
2657
2658 let auth_kind = meta.auth.clone();
2659
2660 let ports = service_home(service_name)
2666 .ok()
2667 .and_then(|home| std::fs::read_to_string(home.join(".env")).ok())
2668 .map(|env| {
2669 env.lines()
2670 .filter_map(|l| {
2671 let l = l.trim();
2672 if l.is_empty() || l.starts_with('#') {
2673 return None;
2674 }
2675 let (key, val) = l.split_once('=')?;
2676 let name = key.strip_prefix("SERVICE_PORT_")?.to_lowercase();
2677 let port = val
2678 .trim_matches(|c: char| c == '"' || c == '\'')
2679 .parse::<u16>()
2680 .ok()?;
2681 Some((name, port))
2682 })
2683 .collect::<std::collections::BTreeMap<String, u16>>()
2684 })
2685 .unwrap_or_default();
2686
2687 Some(InstalledService {
2688 name: service_name.to_string(),
2689 version: "0.1.0".to_string(),
2690 repo: meta.registry,
2691 ports,
2692 auth_kind,
2693 exposure,
2694 provides: meta.provides,
2695 installed: true,
2696 })
2697}
2698
2699pub fn list_installed() -> Result<Vec<InstalledService>> {
2706 let mut names: std::collections::BTreeSet<String> = scan_managed_services()
2707 .unwrap_or_default()
2708 .into_iter()
2709 .collect();
2710 if let Ok(root) = service_data_root()
2714 && let Ok(entries) = std::fs::read_dir(&root)
2715 {
2716 for entry in entries.flatten() {
2717 if let Some(name) = entry.file_name().to_str()
2718 && !names.contains(name)
2719 && is_service_installed(name)
2720 {
2721 names.insert(name.to_string());
2722 }
2723 }
2724 }
2725 let out: Vec<InstalledService> = names
2726 .iter()
2727 .filter_map(|n| build_installed_from_metadata(n))
2728 .collect();
2729 Ok(out)
2730}
2731
2732pub fn search_services(repo_dir: &Path, query: Option<&str>) -> Result<Vec<SearchResult>> {
2734 let available = registry::list_available(repo_dir)?;
2735
2736 let results = available
2737 .into_iter()
2738 .filter(|reg_svc| match query {
2739 None => true,
2740 Some(q) => {
2741 let q = q.to_lowercase();
2742 reg_svc.def.service.name.to_lowercase().contains(&q)
2743 || reg_svc.def.service.description.to_lowercase().contains(&q)
2744 }
2745 })
2746 .map(|reg_svc| {
2747 let name = ®_svc.def.service.name;
2748 let installed = is_service_installed(name);
2749 let mut supports = Vec::new();
2750 for kind in ®_svc.def.integrations.auth {
2751 supports.push(kind.to_string());
2752 }
2753 if reg_svc.def.integrations.smtp {
2754 supports.push("smtp".to_string());
2755 }
2756 SearchResult {
2757 name: name.clone(),
2758 description: reg_svc.def.service.description,
2759 installed,
2760 supports,
2761 }
2762 })
2763 .collect();
2764
2765 Ok(results)
2766}
2767
2768pub struct SearchResult {
2769 pub name: String,
2770 pub description: String,
2771 pub installed: bool,
2772 pub supports: Vec<String>,
2774}
2775
2776pub async fn service_tests(service_name: &str) -> Result<ServiceTestInfo> {
2778 let installed = build_installed_from_metadata(service_name)
2779 .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
2780
2781 let service_ref = service_ref_from_installed(&installed);
2782 let repo_dir = resolve_registry_dir(&service_ref).await?;
2783
2784 let test_toml_path = repo_dir.join(service_name).join("test.toml");
2785 let env_file = service_home(service_name)?.join(".env");
2786
2787 if !test_toml_path.exists() {
2788 return Ok(ServiceTestInfo {
2789 service_name: service_name.to_string(),
2790 registry_name: service_ref.registry_name().to_string(),
2791 tests: vec![],
2792 env_file,
2793 });
2794 }
2795
2796 let content = std::fs::read_to_string(&test_toml_path).map_err(|source| Error::FileRead {
2797 path: test_toml_path.clone(),
2798 source,
2799 })?;
2800
2801 #[derive(serde::Deserialize)]
2802 struct TestFile {
2803 #[serde(default)]
2804 tests: Vec<registry::test_def::TestDef>,
2805 }
2806
2807 let parsed: TestFile = toml::from_str(&content).map_err(|source| Error::TomlParse {
2808 path: test_toml_path,
2809 source,
2810 })?;
2811
2812 Ok(ServiceTestInfo {
2813 service_name: service_name.to_string(),
2814 registry_name: service_ref.registry_name().to_string(),
2815 tests: parsed.tests,
2816 env_file,
2817 })
2818}
2819
2820pub struct ServiceTestInfo {
2821 pub service_name: String,
2822 pub registry_name: String,
2823 pub tests: Vec<registry::test_def::TestDef>,
2824 pub env_file: PathBuf,
2825}
2826
2827pub fn service_info(repo_dir: &Path, service_name: &str) -> Result<ServiceDetail> {
2829 let reg_service = registry::find_service(repo_dir, service_name)?;
2830 let def = ®_service.def;
2831
2832 Ok(ServiceDetail {
2833 name: def.service.name.clone(),
2834 description: def.service.description.clone(),
2835 url: def.service.url.clone(),
2836 ports: def
2837 .ports
2838 .iter()
2839 .map(|p| (p.container_port, p.protocol.clone(), p.name.clone()))
2840 .collect(),
2841 env_vars: def
2842 .env
2843 .iter()
2844 .map(|e| (e.name.clone(), e.prompt.clone()))
2845 .collect(),
2846 })
2847}
2848
2849pub struct ServiceDetail {
2850 pub name: String,
2851 pub description: String,
2852 pub url: Option<String>,
2853 pub ports: Vec<(u16, registry::service_def::PortProtocol, String)>,
2854 pub env_vars: Vec<(String, Option<String>)>,
2855}
2856
2857#[cfg(test)]
2858mod tests {
2859 use super::*;
2860
2861 fn write_demo_registry(tmp: &std::path::Path, deploy_line: &str) {
2866 let svc_dir = tmp.join("demo");
2867 std::fs::create_dir_all(svc_dir.join("quadlets")).unwrap();
2868 std::fs::write(
2869 svc_dir.join("service.toml"),
2870 format!(
2871 "[service]\n\
2872 name = \"demo\"\n\
2873 description = \"demo\"\n\
2874 runtime = \"podman\"\n\
2875 {deploy_line}\n\
2876 \n\
2877 [[ports]]\n\
2878 name = \"http\"\n\
2879 container_port = 8080\n"
2880 ),
2881 )
2882 .unwrap();
2883 std::fs::write(
2884 svc_dir.join("quadlets").join("demo.container"),
2885 "[Container]\n\
2886 Image=docker.io/traefik/whoami:latest\n\
2887 ContainerName=demo\n\
2888 PublishPort=${SERVICE_PORT_HTTP}:8080\n\
2889 EnvironmentFile=%h/.local/share/services/demo/.env\n\
2890 \n\
2891 [Service]\n\
2892 EnvironmentFile=%h/.local/share/services/demo/.env\n\
2893 \n\
2894 [Install]\n\
2895 WantedBy=default.target\n",
2896 )
2897 .unwrap();
2898 }
2899
2900 fn write_native_registry(tmp: &std::path::Path) {
2903 let svc_dir = tmp.join("napp");
2904 std::fs::create_dir_all(&svc_dir).unwrap();
2905 std::fs::write(
2906 svc_dir.join("service.toml"),
2907 "[service]\n\
2908 name = \"napp\"\n\
2909 description = \"native demo\"\n\
2910 runtime = \"native\"\n\
2911 run = \"python -m app\"\n\
2912 build = \"pip install -r requirements.txt\"\n\
2913 deploy = \"blue-green\"\n\
2914 health_check = \"/healthz\"\n\
2915 \n\
2916 [[ports]]\n\
2917 name = \"http\"\n\
2918 container_port = 8080\n",
2919 )
2920 .unwrap();
2921 std::fs::write(svc_dir.join("app.py"), "print('hi')\n").unwrap();
2923 }
2924
2925 fn plan_demo(tmp: &std::path::Path) -> AddResult {
2926 plan_service(tmp, "demo")
2927 }
2928
2929 fn plan_service(tmp: &std::path::Path, name: &'static str) -> AddResult {
2930 plan_service_exposed(tmp, name, exposure::Exposure::Loopback)
2931 }
2932
2933 fn plan_service_exposed(
2934 tmp: &std::path::Path,
2935 name: &'static str,
2936 exposure: exposure::Exposure,
2937 ) -> AddResult {
2938 let empty_map = std::collections::BTreeMap::new();
2939 let empty_ports: std::collections::BTreeMap<String, u16> =
2940 std::collections::BTreeMap::new();
2941 let empty_set = std::collections::BTreeSet::new();
2942 let port_in_use = |_p: u16| false;
2943 add_service(AddServiceParams {
2944 service_name: name,
2945 exposure: &exposure,
2946 auth: AuthChoice::None,
2947 enable_smtp: false,
2948 enable_backup: false,
2949 env_overrides: &empty_map,
2950 enabled_groups: &empty_set,
2951 selected_choices: &empty_map,
2952 registry_name: "test",
2953 repo_dir: tmp,
2954 pre_built_ctx: None,
2955 port_in_use: &port_in_use,
2956 acme_mode: None,
2957 mode: PlanMode::Add,
2958 port_overrides: &empty_ports,
2959 })
2960 .expect("plan add")
2961 }
2962
2963 #[test]
2967 fn blue_green_podman_add_emits_two_slots_and_starts_blue() {
2968 let tmp = tempfile::tempdir().unwrap();
2969 write_demo_registry(
2970 tmp.path(),
2971 "deploy = \"blue-green\"\nhealth_check = \"/healthz\"",
2972 );
2973 let result = plan_demo(tmp.path());
2974
2975 let written: Vec<String> = result
2978 .steps
2979 .iter()
2980 .filter_map(|s| match s {
2981 Step::WriteFile(f) => f
2982 .path
2983 .file_name()
2984 .and_then(|n| n.to_str())
2985 .map(String::from),
2986 _ => None,
2987 })
2988 .collect();
2989 assert!(
2990 written.iter().any(|n| n == "demo-blue.container"),
2991 "got {written:?}"
2992 );
2993 assert!(
2994 written.iter().any(|n| n == "demo-green.container"),
2995 "got {written:?}"
2996 );
2997 assert!(
2998 !written.iter().any(|n| n == "demo.container"),
2999 "bare slot leaked: {written:?}"
3000 );
3001
3002 let blue = result
3004 .steps
3005 .iter()
3006 .find_map(|s| match s {
3007 Step::WriteFile(f) if f.path.ends_with("demo-blue.container") => Some(&f.content),
3008 _ => None,
3009 })
3010 .unwrap();
3011 assert!(blue.contains("ContainerName=demo-blue"));
3012 assert!(blue.contains("${SERVICE_PORT_HTTP_BLUE}"));
3013
3014 let started: Vec<&str> = result
3016 .steps
3017 .iter()
3018 .filter_map(|s| match s {
3019 Step::StartService { unit } => Some(unit.as_str()),
3020 _ => None,
3021 })
3022 .collect();
3023 assert!(started.contains(&"demo-blue"), "started: {started:?}");
3024 assert!(!started.contains(&"demo"), "bare unit started: {started:?}");
3025
3026 let env = result
3028 .steps
3029 .iter()
3030 .find_map(|s| match s {
3031 Step::WriteFile(f) if f.path.file_name() == Some(std::ffi::OsStr::new(".env")) => {
3032 Some(&f.content)
3033 }
3034 _ => None,
3035 })
3036 .unwrap();
3037 assert!(env.contains("SERVICE_PORT_HTTP_BLUE="), "env: {env}");
3038 assert!(env.contains("SERVICE_PORT_HTTP_GREEN="), "env: {env}");
3039 }
3040
3041 #[test]
3045 fn blue_green_native_add_syncs_builds_and_starts_blue() {
3046 let tmp = tempfile::tempdir().unwrap();
3047 write_native_registry(tmp.path());
3048 let result = plan_service(tmp.path(), "napp");
3049
3050 let syncs: Vec<String> = result
3052 .steps
3053 .iter()
3054 .filter_map(|s| match s {
3055 Step::SyncDir { dst, .. } => Some(dst.to_string_lossy().into_owned()),
3056 _ => None,
3057 })
3058 .collect();
3059 assert!(
3060 syncs.iter().any(|d| d.ends_with("colors/blue")),
3061 "syncs: {syncs:?}"
3062 );
3063 assert!(
3064 syncs.iter().any(|d| d.ends_with("colors/green")),
3065 "syncs: {syncs:?}"
3066 );
3067 let builds: Vec<String> = result
3068 .steps
3069 .iter()
3070 .filter_map(|s| match s {
3071 Step::Build { dir, .. } => Some(dir.to_string_lossy().into_owned()),
3072 _ => None,
3073 })
3074 .collect();
3075 assert!(
3076 builds.iter().any(|d| d.ends_with("colors/blue")),
3077 "builds: {builds:?}"
3078 );
3079 assert!(
3080 builds.iter().any(|d| d.ends_with("colors/green")),
3081 "builds: {builds:?}"
3082 );
3083
3084 let green_unit = result
3087 .steps
3088 .iter()
3089 .find_map(|s| match s {
3090 Step::WriteFile(f) if f.path.ends_with("napp-green.service") => Some(&f.content),
3091 _ => None,
3092 })
3093 .expect("green unit");
3094 assert!(green_unit.contains("WorkingDirectory="));
3095 assert!(green_unit.contains("colors/green"));
3096 assert!(green_unit.contains("Environment=SERVICE_PORT_HTTP="));
3097 assert!(green_unit.contains("ExecStart=/bin/sh -c 'exec python -m app'"));
3098
3099 let started: Vec<&str> = result
3101 .steps
3102 .iter()
3103 .filter_map(|s| match s {
3104 Step::StartService { unit } => Some(unit.as_str()),
3105 _ => None,
3106 })
3107 .collect();
3108 assert_eq!(started, vec!["napp-blue"], "started: {started:?}");
3109
3110 let env = result
3112 .steps
3113 .iter()
3114 .find_map(|s| match s {
3115 Step::WriteFile(f) if f.path.file_name() == Some(std::ffi::OsStr::new(".env")) => {
3116 Some(&f.content)
3117 }
3118 _ => None,
3119 })
3120 .unwrap();
3121 assert!(env.contains("SERVICE_PORT_HTTP_BLUE="));
3122 assert!(env.contains("SERVICE_PORT_HTTP_GREEN="));
3123 }
3124
3125 #[test]
3131 fn blue_green_native_add_with_url_warns_when_no_caddy() {
3132 let tmp = tempfile::tempdir().unwrap();
3133 write_native_registry(tmp.path());
3134 let result = plan_service_exposed(
3135 tmp.path(),
3136 "napp",
3137 exposure::Exposure::Public {
3138 url: "https://napp.example.com".into(),
3139 },
3140 );
3141 assert!(
3142 result
3143 .warnings
3144 .iter()
3145 .any(|w| matches!(w, Warning::UrlWithoutReverseProxy { .. })),
3146 "native + url + no caddy should warn UrlWithoutReverseProxy"
3147 );
3148 }
3149
3150 #[test]
3153 fn restart_podman_add_is_unchanged() {
3154 let tmp = tempfile::tempdir().unwrap();
3155 write_demo_registry(tmp.path(), "");
3156 let result = plan_demo(tmp.path());
3157 let written: Vec<String> = result
3158 .steps
3159 .iter()
3160 .filter_map(|s| match s {
3161 Step::WriteFile(f) => f
3162 .path
3163 .file_name()
3164 .and_then(|n| n.to_str())
3165 .map(String::from),
3166 _ => None,
3167 })
3168 .collect();
3169 assert!(
3170 written.iter().any(|n| n == "demo.container"),
3171 "got {written:?}"
3172 );
3173 assert!(
3174 !written.iter().any(|n| n.contains("-blue")),
3175 "got {written:?}"
3176 );
3177 let started: Vec<&str> = result
3178 .steps
3179 .iter()
3180 .filter_map(|s| match s {
3181 Step::StartService { unit } => Some(unit.as_str()),
3182 _ => None,
3183 })
3184 .collect();
3185 assert!(started.contains(&"demo"));
3186 }
3187
3188 #[test]
3189 fn static_template_filter_excludes_secrets_and_credentials() {
3190 assert!(is_static_template("3306"));
3192 assert!(is_static_template("mariadb"));
3193 assert!(is_static_template("{{service.port}}"));
3195 assert!(is_static_template("{{service.url}}"));
3196 assert!(is_static_template("{{auth.url}}"));
3197 assert!(is_static_template("{{auth.issuer}}"));
3198 assert!(is_static_template("{{auth.provider}}"));
3199 assert!(is_static_template("{{auth.internal_url}}"));
3200 assert!(is_static_template("{{smtp.host}}"));
3201 assert!(is_static_template("{{smtp.port}}"));
3202 assert!(is_static_template("{{smtp.from}}"));
3203 assert!(is_static_template("{{service.url}}/oauth/callback"));
3205
3206 assert!(!is_static_template("{{secret.admin_password}}"));
3208 assert!(!is_static_template("{{secret.jwt_key}}"));
3209 assert!(!is_static_template("{{auth.client_id}}"));
3211 assert!(!is_static_template("{{auth.client_secret}}"));
3212 assert!(!is_static_template("{{smtp.username}}"));
3214 assert!(!is_static_template("{{smtp.password}}"));
3215 assert!(!is_static_template(
3217 "redis://:{{secret.redis_pw}}@host:6379"
3218 ));
3219 }
3220
3221 #[test]
3222 fn tailscale_url_matches() {
3223 assert!(is_tailscale_url("http://debian.cobbler-tuna.ts.net"));
3224 assert!(is_tailscale_url("http://debian.cobbler-tuna.ts.net:10001/"));
3225 assert!(is_tailscale_url("https://foo.example-net.ts.net"));
3226 assert!(is_tailscale_url("http://HOST.COBBLER-TUNA.TS.NET"));
3227 }
3228
3229 #[test]
3230 fn tailscale_url_rejects() {
3231 assert!(!is_tailscale_url("https://nextcloud.internal:8443"));
3232 assert!(!is_tailscale_url("https://example.com"));
3233 assert!(!is_tailscale_url("http://127.0.0.1:10001"));
3234 assert!(!is_tailscale_url("https://ts.net"));
3236 assert!(!is_tailscale_url("https://evil-ts.net.example.com"));
3237 assert!(!is_tailscale_url("not a url"));
3238 }
3239
3240 #[test]
3241 fn public_url_accepts_public_domains() {
3242 assert!(is_public_url("https://seafile.ryra.no"));
3243 assert!(is_public_url("https://example.com"));
3244 assert!(is_public_url("https://docs.ryra.no:8443"));
3245 }
3246
3247 #[test]
3248 fn public_url_rejects_lan_and_tailnet() {
3249 assert!(!is_public_url("https://nextcloud.internal:8443"));
3250 assert!(!is_public_url("https://service.localhost"));
3251 assert!(!is_public_url("https://something.local"));
3252 assert!(!is_public_url("https://localhost:8080"));
3253 assert!(!is_public_url("https://debian.cobbler-tuna.ts.net"));
3254 assert!(!is_public_url("http://127.0.0.1:10001"));
3255 assert!(!is_public_url("http://192.168.1.10"));
3256 assert!(!is_public_url("http://[::1]"));
3257 assert!(!is_public_url("not a url"));
3258 }
3259
3260 #[test]
3265 fn networks_empty_when_no_auth() {
3266 let nets = resolve_extra_networks(
3267 "whoami", false, false, false, false, false, false, None, false,
3268 );
3269 assert!(nets.is_empty());
3270 }
3271
3272 #[test]
3273 fn networks_empty_when_auth_but_no_authelia() {
3274 let nets = resolve_extra_networks(
3275 "forgejo", true, false, false, false, false, false, None, false,
3276 );
3277 assert!(nets.is_empty());
3278 }
3279
3280 #[test]
3281 fn networks_authelia_when_auth_enabled() {
3282 let nets = resolve_extra_networks(
3283 "forgejo", true, true, false, false, false, false, None, false,
3284 );
3285 assert_eq!(nets, vec!["authelia"]);
3286 }
3287
3288 #[test]
3289 fn networks_auth_with_caddy_includes_both() {
3290 let nets = resolve_extra_networks(
3291 "forgejo", true, true, true, false, false, false, None, false,
3292 );
3293 assert!(nets.contains(&"authelia".to_string()));
3294 assert!(nets.contains(&"caddy".to_string()));
3295 }
3296
3297 #[test]
3298 fn networks_authelia_excluded_for_authelia_itself() {
3299 let nets = resolve_extra_networks(
3300 "authelia", true, true, false, false, false, false, None, false,
3301 );
3302 assert!(nets.is_empty());
3303 }
3304
3305 #[test]
3306 fn networks_smtp_joins_inbucket_without_caddy() {
3307 let nets = resolve_extra_networks(
3309 "forgejo", false, false, false, true, false, true, None, false,
3310 );
3311 assert_eq!(nets, vec!["inbucket"]);
3312 }
3313
3314 #[test]
3315 fn networks_smtp_skips_inbucket_when_it_is_self() {
3316 let nets = resolve_extra_networks(
3317 "inbucket", false, false, false, true, false, true, None, false,
3318 );
3319 assert!(!nets.contains(&"inbucket".to_string()));
3320 }
3321
3322 #[test]
3323 fn networks_smtp_skips_inbucket_when_not_installed() {
3324 let nets = resolve_extra_networks(
3325 "forgejo", false, false, false, false, false, true, None, false,
3326 );
3327 assert!(!nets.contains(&"inbucket".to_string()));
3328 }
3329
3330 #[test]
3331 fn networks_metrics_consumer_joins_store() {
3332 let nets = resolve_extra_networks(
3333 "grafana",
3334 false,
3335 false,
3336 false,
3337 false,
3338 false,
3339 false,
3340 Some("prometheus"),
3341 true,
3342 );
3343 assert_eq!(nets, vec!["prometheus".to_string()]);
3344 }
3345
3346 #[test]
3347 fn networks_metrics_store_skips_itself() {
3348 let nets = resolve_extra_networks(
3349 "prometheus",
3350 false,
3351 false,
3352 false,
3353 false,
3354 false,
3355 false,
3356 Some("prometheus"),
3357 true,
3358 );
3359 assert!(nets.is_empty());
3360 }
3361
3362 #[test]
3363 fn networks_metrics_indifferent_service_skips_store() {
3364 let nets = resolve_extra_networks(
3365 "vaultwarden",
3366 false,
3367 false,
3368 false,
3369 false,
3370 false,
3371 false,
3372 Some("prometheus"),
3373 false,
3374 );
3375 assert!(nets.is_empty());
3376 }
3377
3378 #[test]
3379 fn quadlet_belongs_to_exact_match() {
3380 let all = &["foo", "foo-bar"];
3381 assert!(quadlet_belongs_to("foo.container", "foo", all));
3382 assert!(quadlet_belongs_to("foo.network", "foo", all));
3383 }
3384
3385 #[test]
3386 fn quadlet_belongs_to_sidecar() {
3387 let all = &["foo"];
3389 assert!(quadlet_belongs_to("foo-db.volume", "foo", all));
3390 }
3391
3392 #[test]
3393 fn quadlet_belongs_to_rejects_prefix_collision() {
3394 let all = &["foo", "foo-bar"];
3395 assert!(!quadlet_belongs_to("foo-bar.container", "foo", all));
3396 assert!(!quadlet_belongs_to("foo-bar-db.volume", "foo", all));
3397 }
3398
3399 #[test]
3400 fn quadlet_belongs_to_hyphenated_service() {
3401 let all = &["foo", "foo-bar"];
3402 assert!(quadlet_belongs_to("foo-bar.container", "foo-bar", all));
3403 assert!(quadlet_belongs_to("foo-bar-db.volume", "foo-bar", all));
3404 assert!(!quadlet_belongs_to("foo.container", "foo-bar", all));
3405 }
3406}