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 generate;
13pub mod manifest;
14pub mod metadata;
15pub mod metrics_bridge;
16pub mod ops;
17pub mod paths;
18pub mod plan;
19pub mod registry;
20pub mod system;
21pub mod upgrade;
22pub mod well_known;
23
24use std::collections::BTreeMap;
25use std::path::{Path, PathBuf};
26
27use config::ConfigPaths;
28use config::schema::InstalledService;
29use error::{Error, Result};
30
31pub use capability::{
32 Capability, any_installed_provider, find_installed_provider, installed_provides,
33 service_provides,
34};
35pub use configure::{
36 ConfigureChange, ConfigureResult, EnvKeyChange, ExposureChange,
37 Overrides as ConfigureOverrides, ServiceReconcile, configure_service, reconcile_service,
38};
39pub use exposure::{
40 Exposure, check_auth_exposure_compat, is_caddy_local_url, is_public_url, is_tailscale_url,
41};
42pub use generate::GeneratedFile;
43pub use manifest::{ManifestEntry, manifest_path};
44pub use metadata::{Metadata, load_metadata};
45pub use paths::{
46 CONFIG_DIR_ENV, DATA_DIR_ENV, DEFAULT_REGISTRY_URL, REGISTRY_DEFAULT, REGISTRY_DIR_ENV,
47 metadata_path, quadlet_dir, service_data_root, service_home, systemd_user_dir,
48};
49pub use plan::{AddResult, RemoveResult, ResetResult, Step, TailscalePort, TrackedEnv, Warning};
50pub use upgrade::{
51 BackupSnapshot, DEFAULT_BACKUP_KEEP, DiffEntry, DiffKind, DiffResult, EnvAddition,
52 RevertResult, UpgradeResult, diff_service, list_backups, prune_backups, revert_service,
53 upgrade_service,
54};
55pub use well_known::WellKnownService;
56
57pub(crate) use paths::home_dir;
58pub(crate) use well_known::caddy_https_port;
59
60pub async fn resolve_registry_dir(service_ref: ®istry::resolve::ServiceRef) -> Result<PathBuf> {
62 let paths = ConfigPaths::resolve()?;
63 paths.ensure_cache_dir()?;
64 let config = config::load_or_default(&paths.config_file)?;
65 registry::resolve::resolve_registry_dir(service_ref, &config, &paths.cache_dir).await
66}
67
68pub fn service_ref_from_installed(installed: &InstalledService) -> registry::resolve::ServiceRef {
70 if installed.repo.is_empty() || installed.repo == REGISTRY_DEFAULT {
71 registry::resolve::ServiceRef::Default(installed.name.clone())
72 } else {
73 registry::resolve::ServiceRef::Custom {
74 registry: installed.repo.clone(),
75 service: installed.name.clone(),
76 }
77 }
78}
79
80fn retroactive_network_joins(
89 new_service: &str,
90 quadlet_path: &std::path::Path,
91 _repo_dir: Option<&std::path::Path>,
92) -> Vec<Step> {
93 let mut steps = Vec::new();
94 let new_cap = if service_provides(new_service, Capability::ReverseProxy) {
99 Capability::ReverseProxy
100 } else if service_provides(new_service, Capability::SmtpRelay) {
101 Capability::SmtpRelay
102 } else {
103 return steps;
104 };
105
106 let installed = list_installed().unwrap_or_default();
107 for svc in &installed {
108 if !svc.provides.is_empty() {
111 continue;
112 }
113 let (network_name, should_join) = match new_cap {
114 Capability::ReverseProxy => {
115 let wants_proxy = matches!(
119 svc.exposure,
120 Exposure::Internal { .. } | Exposure::Public { .. }
121 );
122 (new_service.to_string(), wants_proxy)
123 }
124 Capability::SmtpRelay => {
125 (
128 new_service.to_string(),
129 service_uses_smtp_relay(&svc.name, new_service),
130 )
131 }
132 Capability::OidcProvider
138 | Capability::ForwardAuthProvider
139 | Capability::MetricsStore
140 | Capability::MetricsDashboard => {
141 continue;
142 }
143 };
144 if !should_join {
145 continue;
146 }
147 let installed_names_owned: Vec<String> = installed.iter().map(|s| s.name.clone()).collect();
148 let all_service_names: Vec<&str> =
149 installed_names_owned.iter().map(|s| s.as_str()).collect();
150 steps.extend(network_join_steps(
151 &svc.name,
152 &network_name,
153 quadlet_path,
154 &all_service_names,
155 ));
156 }
157 steps
158}
159
160fn network_join_steps(
170 svc_name: &str,
171 network_name: &str,
172 quadlet_path: &std::path::Path,
173 all_service_names: &[&str],
174) -> Vec<Step> {
175 let mut steps = Vec::new();
176 let marker = format!("Network={network_name}.network");
177 let mut units_to_restart: Vec<String> = Vec::new();
178 let Ok(entries) = std::fs::read_dir(quadlet_path) else {
179 return steps;
180 };
181 for entry in entries.flatten() {
182 let path = entry.path();
183 let name = match path.file_name().and_then(|n| n.to_str()) {
184 Some(n) if n.ends_with(".container") => n.to_string(),
185 _ => continue,
186 };
187 if !quadlet_belongs_to(&name, svc_name, all_service_names) {
188 continue;
189 }
190 let content = match std::fs::read_to_string(&path) {
191 Ok(c) => c,
192 Err(_) => continue,
193 };
194 if content.contains(&marker) {
195 continue;
196 }
197 let real_path = match std::fs::canonicalize(&path) {
201 Ok(p) => p,
202 Err(_) => continue,
203 };
204 let updated = generate::bundle::inject_networks(
205 &content,
206 std::slice::from_ref(&network_name.to_string()),
207 );
208 steps.push(Step::WriteFile(GeneratedFile {
209 path: real_path,
210 content: updated,
211 }));
212 let unit = name.trim_end_matches(".container").to_string();
215 units_to_restart.push(unit);
216 }
217 if !units_to_restart.is_empty() {
218 steps.push(Step::DaemonReload);
219 for unit in units_to_restart {
220 steps.push(Step::RestartService { unit });
221 }
222 }
223 steps
224}
225
226fn store_container_port(store_name: &str) -> Option<u16> {
229 let def = capability::lookup_registry_def(store_name)?;
230 def.ports
231 .iter()
232 .find(|p| p.name.eq_ignore_ascii_case("http"))
233 .or_else(|| def.ports.first())
234 .map(|p| p.container_port)
235}
236
237fn retroactive_metrics_wiring(
243 store_name: &str,
244 store_def: ®istry::service_def::ServiceDef,
245 quadlet_path: &std::path::Path,
246) -> Vec<Step> {
247 let mut steps = Vec::new();
248 let installed = list_installed().unwrap_or_default();
249 let store_port = store_def
250 .ports
251 .iter()
252 .find(|p| p.name.eq_ignore_ascii_case("http"))
253 .or_else(|| store_def.ports.first())
254 .map(|p| p.container_port);
255 let installed_names_owned: Vec<String> = installed.iter().map(|s| s.name.clone()).collect();
256 let all_service_names: Vec<&str> = installed_names_owned.iter().map(|s| s.as_str()).collect();
257
258 for svc in &installed {
259 if svc.name == store_name {
260 continue;
261 }
262 let Some(def) = capability::lookup_registry_def(&svc.name) else {
266 continue;
267 };
268 let mut dashboard_wired = false;
269 let mut needs_join = false;
270 let metrics_host_port = def.metrics.as_ref().and_then(|m| {
275 upgrade::read_existing_ports(&svc.name)
276 .ok()
277 .and_then(|ports| ports.get(&m.port.to_ascii_lowercase()).copied())
278 });
279 if let Ok(Some(step)) =
280 metrics_bridge::scrape_target_step(store_name, &def, metrics_host_port)
281 {
282 steps.push(step);
283 needs_join = def.metrics.as_ref().is_some_and(|m| !m.host_network);
284 }
285 if def
286 .capabilities
287 .provides
288 .contains(&Capability::MetricsDashboard)
289 && let Some(port) = store_port
290 && let Ok(step) = metrics_bridge::datasource_step(&svc.name, store_name, port)
291 {
292 steps.push(step);
293 dashboard_wired = true;
294 needs_join = true;
295 }
296 if !needs_join {
297 continue;
298 }
299 let join_steps =
300 network_join_steps(&svc.name, store_name, quadlet_path, &all_service_names);
301 let restarts_main = join_steps
305 .iter()
306 .any(|s| matches!(s, Step::RestartService { unit } if unit == &svc.name));
307 steps.extend(join_steps);
308 if dashboard_wired && !restarts_main {
309 steps.push(Step::RestartService {
310 unit: svc.name.clone(),
311 });
312 }
313 }
314 steps
315}
316
317fn service_uses_smtp_relay(service_name: &str, relay_host: &str) -> bool {
323 let env_path = match service_home(service_name) {
324 Ok(h) => h.join(".env"),
325 Err(_) => return false,
326 };
327 let content = match std::fs::read_to_string(&env_path) {
328 Ok(c) => c,
329 Err(_) => return false,
330 };
331 let with_port = format!("{relay_host}:");
332 content.lines().any(|line| {
333 let Some((_, value)) = line.split_once('=') else {
334 return false;
335 };
336 let v = value.trim();
337 v == relay_host || v.starts_with(&with_port)
338 })
339}
340
341#[allow(clippy::too_many_arguments)]
356fn resolve_extra_networks(
357 service_name: &str,
358 enable_auth: bool,
359 authelia_installed: bool,
360 caddy_installed: bool,
361 inbucket_installed: bool,
362 has_url: bool,
363 has_smtp: bool,
364 metrics_store: Option<&str>,
365 wants_metrics: bool,
366) -> Vec<String> {
367 let mut networks = Vec::new();
368 if enable_auth && authelia_installed && !WellKnownService::Authelia.matches(service_name) {
369 networks.push(WellKnownService::Authelia.to_string());
370 }
371 let joins_inbucket =
374 has_smtp && inbucket_installed && !WellKnownService::Inbucket.matches(service_name);
375 if joins_inbucket {
376 networks.push(WellKnownService::Inbucket.to_string());
377 }
378 let joins_caddy = (has_url || enable_auth || WellKnownService::Inbucket.matches(service_name))
379 && caddy_installed
380 && !WellKnownService::Caddy.matches(service_name);
381 if joins_caddy && !networks.contains(&WellKnownService::Caddy.to_string()) {
382 networks.push(WellKnownService::Caddy.to_string());
383 }
384 if let Some(store) = metrics_store
387 && wants_metrics
388 && store != service_name
389 {
390 networks.push(store.to_string());
391 }
392 networks
393}
394
395#[derive(Debug, Clone, Copy, PartialEq, Eq)]
402pub enum PlanMode {
403 Add,
407 Upgrade,
411}
412
413#[derive(Debug, Clone, PartialEq, Eq)]
422pub enum AuthChoice {
423 None,
425 Native(registry::service_def::AuthKind),
428}
429
430impl AuthChoice {
431 pub fn enabled(&self) -> bool {
434 !matches!(self, AuthChoice::None)
435 }
436
437 pub fn native_kind(&self) -> Option<®istry::service_def::AuthKind> {
440 match self {
441 AuthChoice::Native(kind) => Some(kind),
442 AuthChoice::None => None,
443 }
444 }
445}
446
447pub struct AddServiceParams<'a> {
452 pub service_name: &'a str,
453 pub exposure: &'a Exposure,
454 pub auth: AuthChoice,
455 pub enable_smtp: bool,
456 pub enable_backup: bool,
457 pub env_overrides: &'a BTreeMap<String, String>,
458 pub enabled_groups: &'a std::collections::BTreeSet<String>,
459 pub selected_choices: &'a BTreeMap<String, String>,
462 pub registry_name: &'a str,
463 pub repo_dir: &'a Path,
464 pub pre_built_ctx: Option<BTreeMap<String, String>>,
468 pub port_in_use: &'a dyn Fn(u16) -> bool,
469 pub acme_mode: Option<&'a caddy::AcmeMode>,
470 pub mode: PlanMode,
471 pub port_overrides: &'a BTreeMap<String, u16>,
477}
478
479fn caddy_route_steps(
488 service_name: &str,
489 url: &str,
490 target_host: String,
491 upstream_port: u16,
492 host_port: Option<u16>,
493 caddy_installed: bool,
494 https_port: u16,
495) -> Result<(Vec<Step>, Vec<Warning>)> {
496 let mut steps = Vec::new();
497 let mut warnings = Vec::new();
498 if caddy_installed {
499 let parsed = url::Url::parse(url)
500 .map_err(|e| Error::Template(format!("invalid service URL '{url}': {e}")))?;
501 let domain = parsed.host_str().ok_or_else(|| {
502 Error::Template(format!(
503 "service URL '{url}' has no host — Caddy needs a hostname to route to"
504 ))
505 })?;
506 let block = caddy::render_site_block(&caddy::CaddySiteParams {
507 service_name: service_name.to_string(),
508 target_host,
509 domain: domain.to_string(),
510 container_port: upstream_port,
511 https_port,
512 force_internal_tls: false,
513 });
514 let caddyfile_path = caddy::caddyfile_path()?;
515 let existing =
516 std::fs::read_to_string(&caddyfile_path).map_err(|source| Error::FileRead {
517 path: caddyfile_path.clone(),
518 source,
519 })?;
520 let updated = caddy::add_route(&existing, service_name, &block);
521 steps.push(Step::WriteFile(GeneratedFile {
522 path: caddyfile_path,
523 content: updated,
524 }));
525 steps.push(Step::ReloadCaddy);
526 } else if let Some(primary) = host_port {
527 warnings.push(Warning::UrlWithoutReverseProxy {
530 service_name: service_name.to_string(),
531 url: url.to_string(),
532 host_port: primary,
533 });
534 }
535 Ok((steps, warnings))
536}
537
538pub fn add_service(params: AddServiceParams<'_>) -> Result<AddResult> {
540 let AddServiceParams {
541 service_name,
542 exposure,
543 auth,
544 enable_smtp,
545 enable_backup,
546 env_overrides,
547 enabled_groups,
548 selected_choices,
549 registry_name,
550 repo_dir,
551 pre_built_ctx,
552 port_in_use,
553 acme_mode,
554 mode,
555 port_overrides,
556 } = params;
557 let auth_kind: Option<®istry::service_def::AuthKind> = auth.native_kind();
562 let enable_auth: bool = auth.enabled();
563 let url: Option<&str> = exposure.url();
564 let paths = ConfigPaths::resolve()?;
565 let config = config::load_or_default(&paths.config_file)?;
566
567 if mode == PlanMode::Add {
573 if is_service_installed(service_name) {
574 return Err(Error::ServiceAlreadyInstalled(service_name.to_string()));
575 }
576
577 if data::enumerate_service(service_name)?.is_some() {
585 return Err(Error::ServiceIncomplete(service_name.to_string()));
586 }
587 }
588
589 let reg_service = registry::find_service(repo_dir, service_name)?;
590
591 if let Some(msg) = reg_service.def.check_architecture() {
593 return Err(Error::UnsupportedArchitecture(msg));
594 }
595
596 let missing_requires: Vec<&str> = reg_service
601 .def
602 .requires
603 .iter()
604 .filter(|r| !is_service_installed(&r.service))
605 .map(|r| r.service.as_str())
606 .collect();
607 if !missing_requires.is_empty() {
608 return Err(Error::MissingRequiredServices {
609 service: service_name.to_string(),
610 missing: missing_requires.iter().map(|s| s.to_string()).collect(),
611 });
612 }
613
614 if auth_kind.is_some() && config.auth.is_none() {
616 return Err(Error::AuthNotConfigured);
617 }
618
619 if enable_auth
623 && reg_service.def.integrations.auth.is_empty()
624 && !capability::def_provides(®_service.def, Capability::OidcProvider)
625 {
626 return Err(Error::NoOidcSupport(service_name.to_string()));
627 }
628
629 if enable_backup && !reg_service.def.integrations.backup {
633 return Err(Error::BackupNotSupported(service_name.to_string()));
634 }
635
636 for g in enabled_groups {
640 if !reg_service.def.env_groups.iter().any(|eg| &eg.name == g) {
641 let known: Vec<String> = reg_service
642 .def
643 .env_groups
644 .iter()
645 .map(|eg| eg.name.clone())
646 .collect();
647 let hint = if known.is_empty() {
648 " (service defines no env_groups)".to_string()
649 } else {
650 format!(" (known: {})", known.join(", "))
651 };
652 return Err(Error::UnknownEnvGroup {
653 service: service_name.to_string(),
654 group: g.clone(),
655 hint,
656 });
657 }
658 }
659
660 let mut port_warnings: Vec<Warning> = Vec::new();
666 let mut effective_ports: Vec<®istry::service_def::PortDef> =
671 reg_service.def.ports.iter().collect();
672 for choice in ®_service.def.choices {
673 let sel = selected_choices
674 .get(&choice.name)
675 .unwrap_or(&choice.default);
676 if let Some(opt) = choice.options.iter().find(|o| &o.name == sel) {
677 effective_ports.extend(opt.ports.iter());
678 }
679 }
680 let mut claimed: std::collections::HashSet<u16> =
681 effective_ports.iter().filter_map(|p| p.host_port).collect();
682 let mut resolved_ports: Vec<(String, u16)> = Vec::with_capacity(effective_ports.len());
683 for p in effective_ports.iter().copied() {
684 let host = if let Some(pinned) = port_overrides.get(&p.name) {
685 *pinned
690 } else if let Some(hp) = p.host_port {
691 hp
692 } else {
693 let privileged = p.container_port < 1024;
694 let claimed_in_service = claimed.contains(&p.container_port);
695 let in_use = port_in_use(p.container_port);
696 if privileged || claimed_in_service || in_use {
697 let allocated = system::port::allocate_port_excluding(&claimed, port_in_use)?;
698 let reason = if privileged {
699 "port is privileged (requires root)".to_string()
700 } else if claimed_in_service {
701 format!(
702 "port {} is already claimed by another port in this service",
703 p.container_port
704 )
705 } else {
706 format!("port {} is already in use", p.container_port)
707 };
708 port_warnings.push(Warning::PortReassigned {
709 service_name: service_name.to_string(),
710 port_name: p.name.clone(),
711 original_port: p.container_port,
712 assigned_port: allocated,
713 reason,
714 });
715 allocated
716 } else {
717 p.container_port
718 }
719 };
720 claimed.insert(host);
721 resolved_ports.push((p.name.clone(), host));
722 }
723
724 let caddy_direct = selected_choices
733 .get("binding")
734 .map(|s| s == "direct")
735 .unwrap_or(false);
736 if WellKnownService::Caddy.matches(service_name)
737 && caddy_direct
738 && system::sysctl::rootless_can_bind_low_ports()
739 {
740 for (name, port) in resolved_ports.iter_mut() {
741 match name.as_str() {
742 "http" if *port == 8080 => *port = 80,
743 "https" if *port == 8443 => *port = 443,
744 _ => {}
745 }
746 }
747 }
748
749 let host_port = resolved_ports
752 .iter()
753 .find(|(name, _)| name.eq_ignore_ascii_case("http"))
754 .or_else(|| resolved_ports.first())
755 .map(|(_, p)| *p);
756
757 for (_, port) in &resolved_ports {
761 if port_in_use(*port) {
762 return Err(Error::PortConflict { port: *port });
763 }
764 }
765
766 let blue_green =
773 reg_service.def.service.deploy == registry::service_def::DeployStrategy::BlueGreen;
774 if blue_green {
775 let primary = resolved_ports
776 .iter()
777 .find(|(name, _)| name.eq_ignore_ascii_case("http"))
778 .or_else(|| resolved_ports.first())
779 .map(|(name, port)| (name.clone(), *port));
780 if let Some((name, blue_port)) = primary {
781 let green_key = format!("{}_green", name.to_ascii_lowercase());
782 let green_port = match port_overrides.get(&green_key) {
783 Some(pinned) => *pinned,
784 None => {
785 let p = system::port::allocate_port_excluding(&claimed, port_in_use)?;
786 claimed.insert(p);
787 p
788 }
789 };
790 resolved_ports.push((format!("{name}_blue"), blue_port));
791 resolved_ports.push((format!("{name}_green"), green_port));
792 }
793 }
794
795 let home_dir = service_home(service_name)?;
796 let quadlet_path = quadlet_dir()?;
797
798 let installed_now = list_installed().unwrap_or_default();
802 let authelia_installed =
803 find_installed_provider(&installed_now, Capability::OidcProvider).is_some();
804 let caddy_installed =
805 find_installed_provider(&installed_now, Capability::ReverseProxy).is_some();
806 let inbucket_installed =
807 find_installed_provider(&installed_now, Capability::SmtpRelay).is_some();
808 let metrics_store =
809 find_installed_provider(&installed_now, Capability::MetricsStore).map(|s| s.name.clone());
810
811 let provides_auth_infra = reg_service
817 .def
818 .capabilities
819 .provides
820 .iter()
821 .any(|c| matches!(c, Capability::OidcProvider | Capability::ReverseProxy));
822 if enable_auth
823 && !provides_auth_infra
824 && !caddy_installed
825 && let Some(authelia) = find_installed_provider(&installed_now, Capability::OidcProvider)
826 && let Some(auth_url) = authelia.exposure.url()
827 {
828 return Err(Error::AuthRequiresReverseProxy {
829 service: service_name.to_string(),
830 auth_url: auth_url.to_string(),
831 });
832 }
833
834 let auth_bridge = auth_bridge::build(&auth_bridge::AuthBridgeParams {
838 service_name,
839 service_provides: ®_service.def.capabilities.provides,
840 enable_auth,
841 config: &config,
842 installed: &installed_now,
843 service_data: &home_dir,
844 })?;
845
846 let (extra_volumes, extra_env, extra_exec_start_pre, auth_bridge_steps) = match auth_bridge {
847 Some(b) => (b.volumes, b.env, b.exec_start_pre, b.steps),
848 None => (Vec::new(), BTreeMap::new(), Vec::new(), Vec::new()),
849 };
850
851 let has_smtp = enable_smtp
852 && reg_service.def.integrations.smtp
853 && !reg_service.def.mappings.smtp.is_empty()
854 && config.smtp.is_some();
855 let wants_metrics = reg_service
858 .def
859 .metrics
860 .as_ref()
861 .is_some_and(|m| !m.host_network)
862 || capability::def_provides(®_service.def, Capability::MetricsDashboard);
863 let extra_networks = resolve_extra_networks(
864 service_name,
865 enable_auth,
866 authelia_installed,
867 caddy_installed,
868 inbucket_installed,
869 url.is_some(),
870 has_smtp,
871 metrics_store.as_deref(),
872 wants_metrics,
873 );
874
875 let output = generate::generate_env(generate::GenerateEnvParams {
876 config: &config,
877 service_def: ®_service.def,
878 auth_kind,
879 host_port,
880 resolved_ports: &resolved_ports,
881 env_overrides,
882 exposure,
883 extra_env,
884 pre_built_ctx,
885 enable_smtp: has_smtp,
886 enabled_groups,
887 selected_choices,
888 })?;
889
890 let podman_args: Vec<String> = Vec::new();
891
892 let port_names: Vec<String> = resolved_ports.iter().map(|(n, _)| n.clone()).collect();
894
895 let active_color = match reg_service.def.service.deploy {
911 registry::service_def::DeployStrategy::BlueGreen => {
912 Some(registry::service_def::Color::Blue)
913 }
914 registry::service_def::DeployStrategy::Restart => None,
915 };
916 let install_metadata = Metadata {
917 registry: registry_name.to_string(),
918 url: url.map(str::to_string),
919 auth: auth_kind.cloned(),
920 provides: reg_service.def.capabilities.provides.clone(),
921 backup_enabled: enable_backup,
922 smtp_enabled: enable_smtp,
923 enabled_groups: enabled_groups.iter().cloned().collect(),
924 selected_choices: selected_choices.clone(),
925 runtime: reg_service.def.service.runtime.clone(),
926 active_color,
927 };
928
929 if reg_service.def.service.runtime == registry::service_def::Runtime::Native {
934 let tracked_envs = collect_static_envs(
935 ®_service.def,
936 &output.ctx,
937 enabled_groups,
938 selected_choices,
939 )?;
940 let allocated_ports = resolved_ports.clone();
941 let generated_secrets = collect_generated_secrets(®_service.def, env_overrides);
942
943 let native_blue_green =
949 reg_service.def.service.deploy == registry::service_def::DeployStrategy::BlueGreen;
950 let mut caddy_steps: Vec<Step> = Vec::new();
951 let mut native_warnings: Vec<Warning> = Vec::new();
952 if let Some(u) = url
953 && !exposure.is_tailscale()
954 {
955 let upstream_port = if native_blue_green {
956 resolved_ports
957 .iter()
958 .find(|(n, _)| n.eq_ignore_ascii_case("http_blue"))
959 .map(|(_, p)| *p)
960 } else {
961 host_port
962 };
963 if let Some(p) = upstream_port {
964 let (route_steps, route_warnings) = caddy_route_steps(
965 service_name,
966 u,
967 "host.containers.internal".to_string(),
968 p,
969 host_port,
970 caddy_installed,
971 caddy_https_port(&config),
972 )?;
973 caddy_steps = route_steps;
974 native_warnings.extend(route_warnings);
975 }
976 }
977
978 return build_native_add(NativeAddParams {
979 service_name,
980 reg_service: ®_service,
981 home_dir: &home_dir,
982 output,
983 install_metadata: &install_metadata,
984 registry_name,
985 url,
986 tracked_envs,
987 allocated_ports,
988 generated_secrets,
989 excluded_quadlets: excluded_quadlets(®_service.def, selected_choices),
990 caddy_steps,
991 warnings: native_warnings,
992 });
993 }
994
995 let excluded_quadlets = excluded_quadlets(®_service.def, selected_choices);
996
997 let bundle =
999 generate::bundle::process_quadlet_bundle(&generate::bundle::ProcessBundleParams {
1000 service_dir: ®_service.service_dir,
1001 service_name,
1002 extra_networks: &extra_networks,
1003 extra_volumes: &extra_volumes,
1004 podman_args: &podman_args,
1005 extra_exec_start_pre: &extra_exec_start_pre,
1006 port_names: &port_names,
1007 excluded_quadlets: &excluded_quadlets,
1008 })?;
1009
1010 let mut warnings = Vec::new();
1012
1013 if let Some(ref reqs) = reg_service.def.requirements
1014 && let Some(total) = system::memory::total_ram_mb()
1015 {
1016 if total < reqs.ram.min {
1017 warnings.push(Warning::RamBelowMinimum {
1018 service_name: service_name.to_string(),
1019 min_mb: reqs.ram.min,
1020 available_mb: total,
1021 });
1022 } else if let Some(rec) = reqs.ram.recommended
1023 && total < rec
1024 {
1025 warnings.push(Warning::RamBelowRecommended {
1026 service_name: service_name.to_string(),
1027 recommended_mb: rec,
1028 available_mb: total,
1029 });
1030 }
1031 }
1032 warnings.extend(port_warnings);
1033
1034 let mut steps = Vec::new();
1036
1037 steps.push(Step::CreateDir(home_dir.clone()));
1039
1040 let env_content = output.env_file.content.clone();
1042
1043 for image in &bundle.images {
1045 steps.push(Step::PullImage {
1046 image: image.clone(),
1047 });
1048 }
1049
1050 let quadlet_files = if blue_green {
1056 deploy::expand_color_quadlets(bundle.quadlet_files, service_name)
1057 } else {
1058 bundle.quadlet_files
1059 };
1060 for file in quadlet_files {
1061 let link = file
1062 .path
1063 .file_name()
1064 .map(|n| quadlet_path.join(n))
1065 .ok_or_else(|| {
1066 Error::Bundle(format!("invalid quadlet path: {}", file.path.display()))
1067 })?;
1068 let target = file.path.clone();
1069 steps.push(Step::WriteFile(file));
1070 steps.push(Step::Symlink { link, target });
1071 }
1072
1073 let metadata_content = toml::to_string_pretty(&install_metadata)?;
1080 steps.push(Step::WriteFile(GeneratedFile {
1081 path: metadata_path(service_name)?,
1082 content: metadata_content,
1083 }));
1084
1085 if mode == PlanMode::Add && exposure.is_tailscale() {
1093 let svc_name = exposure.tailscale_svc_name().ok_or_else(|| {
1103 Error::InvalidServiceRef(format!(
1104 "tailscale exposure for '{service_name}' has a malformed URL — \
1105 expected `https://<service>-<host>.<tailnet>.ts.net/`"
1106 ))
1107 })?;
1108 let ts_ports = plan::tailscale_ports(®_service.def.ports, &resolved_ports, host_port);
1112 if !ts_ports.is_empty() {
1113 steps.push(Step::TailscaleSetup);
1114 steps.push(Step::TailscaleEnable {
1115 svc_name,
1116 ports: ts_ports,
1117 });
1118 }
1119 }
1120
1121 for file in bundle.config_files {
1123 steps.push(Step::WriteFile(file));
1124 }
1125
1126 for (src, dst) in bundle.files {
1130 steps.push(Step::CopyFile { src, dst });
1131 }
1132
1133 steps.push(Step::WriteFile(output.env_file));
1135
1136 for dir in &bundle.bind_mount_dirs {
1138 steps.push(Step::CreateDir(dir.clone()));
1139 }
1140
1141 steps.extend(auth_bridge_steps);
1145
1146 if mode == PlanMode::Add
1155 && let (
1156 Some(registry::service_def::AuthKind::Oidc),
1157 Some(config::schema::AuthCredentials::Authelia { .. }),
1158 ) = (auth_kind, config.auth.as_ref())
1159 {
1160 steps.extend(authelia::register_oidc_client(
1161 service_name,
1162 ®_service.def,
1163 url,
1164 &output.ctx,
1165 &quadlet_path,
1166 )?);
1167 }
1168
1169 if let Some(url) = url
1175 && !WellKnownService::Caddy.matches(service_name)
1176 && !exposure.is_tailscale()
1177 {
1178 let container_port = reg_service
1179 .def
1180 .ports
1181 .first()
1182 .map(|p| p.container_port)
1183 .unwrap_or(80);
1184 let primary_quadlet = reg_service
1185 .service_dir
1186 .join("quadlets")
1187 .join(format!("{service_name}.container"));
1188 let target_host = if blue_green {
1191 deploy::color_unit(service_name, registry::service_def::Color::Blue)
1192 } else {
1193 caddy::primary_container_name(&primary_quadlet, service_name)
1194 };
1195 let (route_steps, route_warnings) = caddy_route_steps(
1196 service_name,
1197 url,
1198 target_host,
1199 container_port,
1200 host_port,
1201 caddy_installed,
1202 caddy_https_port(&config),
1203 )?;
1204 steps.extend(route_steps);
1205 warnings.extend(route_warnings);
1206 }
1207
1208 if mode == PlanMode::Add {
1218 steps.extend(retroactive_network_joins(
1219 service_name,
1220 &quadlet_path,
1221 Some(repo_dir),
1222 ));
1223 }
1224
1225 if mode == PlanMode::Add {
1232 if let Some(store) = &metrics_store {
1233 let metrics_host_port = reg_service.def.metrics.as_ref().and_then(|m| {
1234 resolved_ports
1235 .iter()
1236 .find(|(n, _)| n == &m.port)
1237 .map(|(_, p)| *p)
1238 });
1239 if let Some(step) =
1240 metrics_bridge::scrape_target_step(store, ®_service.def, metrics_host_port)?
1241 {
1242 steps.push(step);
1243 }
1244 if capability::def_provides(®_service.def, Capability::MetricsDashboard)
1245 && let Some(port) = store_container_port(store)
1246 {
1247 steps.push(metrics_bridge::datasource_step(service_name, store, port)?);
1248 }
1249 }
1250 if capability::def_provides(®_service.def, Capability::MetricsStore) {
1251 steps.extend(retroactive_metrics_wiring(
1252 service_name,
1253 ®_service.def,
1254 &quadlet_path,
1255 ));
1256 }
1257 }
1258
1259 if WellKnownService::Caddy.matches(service_name) {
1266 let snippet_path = caddy::tls_snippet_path()?;
1267 if !snippet_path.exists() {
1268 let mode = acme_mode.cloned().unwrap_or(caddy::AcmeMode::Internal);
1269 steps.push(Step::WriteFile(GeneratedFile {
1270 path: snippet_path,
1271 content: mode.snippet(),
1272 }));
1273 }
1274 }
1275
1276 let manifest_path_for_svc = manifest::manifest_path(service_name)?;
1285 let env_filename = std::ffi::OsStr::new(".env");
1286 let mut manifest_entries: Vec<manifest::ManifestEntry> = Vec::new();
1287 for step in &steps {
1288 if let Step::WriteFile(file) = step {
1289 if file.path == manifest_path_for_svc {
1290 continue;
1291 }
1292 if file.path.file_name() == Some(env_filename) {
1293 continue;
1294 }
1295 manifest_entries.push(manifest::ManifestEntry {
1296 path: file.path.clone(),
1297 sha256: manifest::hash_bytes(file.content.as_bytes()),
1298 });
1299 }
1300 }
1301 let tracked_envs = collect_static_envs(
1309 ®_service.def,
1310 &output.ctx,
1311 enabled_groups,
1312 selected_choices,
1313 )?;
1314 let manifest_envs: Vec<manifest::EnvEntry> = tracked_envs
1315 .iter()
1316 .map(|t| manifest::EnvEntry {
1317 key: t.key.clone(),
1318 value: t.value.clone(),
1319 })
1320 .collect();
1321 steps.push(Step::WriteFile(GeneratedFile {
1322 path: manifest_path_for_svc,
1323 content: manifest::format(&manifest_entries, &manifest_envs),
1324 }));
1325
1326 steps.push(Step::DaemonReload);
1328 let start_unit = if blue_green {
1332 deploy::color_unit(service_name, registry::service_def::Color::Blue)
1333 } else {
1334 service_name.to_string()
1335 };
1336 steps.push(Step::StartService { unit: start_unit });
1337
1338 let allocated_ports: Vec<(String, u16)> = resolved_ports.clone();
1340
1341 let mut generated_secrets: Vec<String> = reg_service
1343 .def
1344 .env
1345 .iter()
1346 .filter(|e| !env_overrides.contains_key(&e.name))
1347 .flat_map(|e| generate::extract_secret_refs(&e.value))
1348 .collect();
1349 generated_secrets.sort();
1351 generated_secrets.dedup();
1352
1353 Ok(AddResult {
1354 steps,
1355 warnings,
1356 repo_url: registry_name.to_string(),
1357 allocated_ports,
1358 generated_secrets,
1359 env_content,
1360 url: url.map(|u| u.to_string()),
1361 tracked_envs,
1362 })
1363}
1364
1365fn collect_generated_secrets(
1368 def: ®istry::service_def::ServiceDef,
1369 env_overrides: &BTreeMap<String, String>,
1370) -> Vec<String> {
1371 let mut out: Vec<String> = def
1372 .env
1373 .iter()
1374 .filter(|e| !env_overrides.contains_key(&e.name))
1375 .flat_map(|e| generate::extract_secret_refs(&e.value))
1376 .collect();
1377 out.sort();
1378 out.dedup();
1379 out
1380}
1381
1382struct NativeAddParams<'a> {
1384 service_name: &'a str,
1385 reg_service: &'a registry::RegistryService,
1386 home_dir: &'a Path,
1387 output: generate::EnvOutput,
1388 install_metadata: &'a Metadata,
1389 registry_name: &'a str,
1390 url: Option<&'a str>,
1391 tracked_envs: Vec<TrackedEnv>,
1392 allocated_ports: Vec<(String, u16)>,
1393 generated_secrets: Vec<String>,
1394 excluded_quadlets: Vec<String>,
1398 caddy_steps: Vec<Step>,
1402 warnings: Vec<Warning>,
1405}
1406
1407fn excluded_quadlets(
1411 def: ®istry::service_def::ServiceDef,
1412 selected_choices: &BTreeMap<String, String>,
1413) -> Vec<String> {
1414 let mut all_claimed: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
1415 let mut selected: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
1416 for choice in &def.choices {
1417 let picked = selected_choices
1418 .get(&choice.name)
1419 .unwrap_or(&choice.default);
1420 for option in &choice.options {
1421 for q in &option.quadlets {
1422 all_claimed.insert(q.clone());
1423 if &option.name == picked {
1424 selected.insert(q.clone());
1425 }
1426 }
1427 }
1428 }
1429 all_claimed.difference(&selected).cloned().collect()
1430}
1431
1432fn build_native_add(p: NativeAddParams<'_>) -> Result<AddResult> {
1438 let NativeAddParams {
1439 service_name,
1440 reg_service,
1441 home_dir,
1442 output,
1443 install_metadata,
1444 registry_name,
1445 url,
1446 tracked_envs,
1447 allocated_ports,
1448 generated_secrets,
1449 excluded_quadlets,
1450 caddy_steps,
1451 warnings,
1452 } = p;
1453
1454 let run = reg_service.def.service.run.as_ref().ok_or_else(|| {
1455 Error::Bundle(format!(
1456 "native service '{service_name}' is missing its `run` command"
1457 ))
1458 })?;
1459 let build = reg_service.def.service.build.as_ref();
1460
1461 let env_content = output.env_file.content.clone();
1462 let source_dir = reg_service.service_dir.clone();
1463 let mut steps = Vec::new();
1464
1465 steps.push(Step::CreateDir(home_dir.to_path_buf()));
1470 steps.push(Step::CreateDir(home_dir.join("data")));
1471
1472 let blue_green =
1473 reg_service.def.service.deploy == registry::service_def::DeployStrategy::BlueGreen;
1474
1475 steps.push(Step::WriteFile(GeneratedFile {
1478 path: metadata_path(service_name)?,
1479 content: toml::to_string_pretty(install_metadata)?,
1480 }));
1481 steps.push(Step::WriteFile(output.env_file));
1482
1483 let description = reg_service.def.service.description.as_str();
1484 if blue_green {
1485 let primary = allocated_ports
1491 .iter()
1492 .find(|(n, _)| n.eq_ignore_ascii_case("http"))
1493 .or_else(|| allocated_ports.first())
1494 .map(|(n, _)| n.clone())
1495 .ok_or_else(|| {
1496 Error::Bundle(format!(
1497 "blue/green native '{service_name}' has no port to route"
1498 ))
1499 })?;
1500 let port_var = format!("SERVICE_PORT_{}", primary.to_uppercase());
1501 let home_str = home_dir.to_string_lossy().into_owned();
1502 for color in [
1503 registry::service_def::Color::Blue,
1504 registry::service_def::Color::Green,
1505 ] {
1506 let slot = home_dir.join("colors").join(color.as_str());
1507 let slot_str = slot.to_string_lossy().into_owned();
1508 let port = allocated_ports
1509 .iter()
1510 .find(|(n, _)| *n == format!("{}_{}", primary.to_ascii_lowercase(), color))
1511 .map(|(_, p)| *p)
1512 .ok_or_else(|| {
1513 Error::Bundle(format!(
1514 "blue/green native '{service_name}' missing the {color} port"
1515 ))
1516 })?;
1517 steps.push(Step::SyncDir {
1519 src: source_dir.clone(),
1520 dst: slot.clone(),
1521 });
1522 if let Some(command) = build {
1523 steps.push(Step::Build {
1524 dir: slot.clone(),
1525 command: command.clone(),
1526 });
1527 }
1528 let unit_name = format!("{}.service", deploy::color_unit(service_name, color));
1529 let unit_path = home_dir.join(&unit_name);
1530 steps.push(Step::WriteFile(GeneratedFile {
1531 path: unit_path.clone(),
1532 content: deploy::native_color_unit(&deploy::NativeColorUnit {
1533 description,
1534 color,
1535 workdir: &slot_str,
1536 home: &home_str,
1537 port_var: &port_var,
1538 port,
1539 run,
1540 }),
1541 }));
1542 steps.push(Step::Symlink {
1543 link: systemd_user_dir()?.join(&unit_name),
1544 target: unit_path,
1545 });
1546 }
1547 } else {
1548 if let Some(command) = build {
1550 steps.push(Step::Build {
1551 dir: source_dir.clone(),
1552 command: command.clone(),
1553 });
1554 }
1555 let unit_name = format!("{service_name}.service");
1558 let unit_path = home_dir.join(&unit_name);
1559 steps.push(Step::WriteFile(GeneratedFile {
1560 path: unit_path.clone(),
1561 content: native_unit(home_dir, &source_dir, run, description),
1562 }));
1563 steps.push(Step::Symlink {
1564 link: systemd_user_dir()?.join(&unit_name),
1565 target: unit_path,
1566 });
1567 }
1568
1569 let mut quadlet_units: Vec<String> = Vec::new();
1576 if source_dir.join("quadlets").is_dir() {
1577 let port_names: Vec<String> = allocated_ports.iter().map(|(n, _)| n.clone()).collect();
1580 let bundle =
1581 generate::bundle::process_quadlet_bundle(&generate::bundle::ProcessBundleParams {
1582 service_dir: &source_dir,
1583 service_name,
1584 extra_networks: &[],
1585 extra_volumes: &[],
1586 podman_args: &[],
1587 extra_exec_start_pre: &[],
1588 port_names: &port_names,
1589 excluded_quadlets: &excluded_quadlets,
1590 })?;
1591 for image in &bundle.images {
1592 steps.push(Step::PullImage {
1593 image: image.clone(),
1594 });
1595 }
1596 for dir in &bundle.bind_mount_dirs {
1597 steps.push(Step::CreateDir(dir.clone()));
1598 }
1599 let quadlet_path = quadlet_dir()?;
1600 for file in bundle.quadlet_files {
1601 let fname = file
1602 .path
1603 .file_name()
1604 .ok_or_else(|| {
1605 Error::Bundle(format!("invalid quadlet path: {}", file.path.display()))
1606 })?
1607 .to_os_string();
1608 if let Some(stem) = fname.to_string_lossy().strip_suffix(".container") {
1609 quadlet_units.push(stem.to_string());
1610 }
1611 let link = quadlet_path.join(&fname);
1612 let target = file.path.clone();
1613 steps.push(Step::WriteFile(file));
1614 steps.push(Step::Symlink { link, target });
1615 }
1616 }
1617
1618 steps.push(Step::DaemonReload);
1619 for unit in &quadlet_units {
1621 steps.push(Step::StartService { unit: unit.clone() });
1622 }
1623 let app_unit = if blue_green {
1626 deploy::color_unit(service_name, registry::service_def::Color::Blue)
1627 } else {
1628 service_name.to_string()
1629 };
1630 steps.push(Step::StartService { unit: app_unit });
1631
1632 steps.extend(caddy_steps);
1635
1636 Ok(AddResult {
1637 steps,
1638 warnings,
1639 repo_url: registry_name.to_string(),
1640 allocated_ports,
1641 generated_secrets,
1642 env_content,
1643 url: url.map(|u| u.to_string()),
1644 tracked_envs,
1645 })
1646}
1647
1648fn native_unit(home_dir: &Path, source_dir: &Path, run: &str, description: &str) -> String {
1653 let home = home_dir.display();
1654 let source = source_dir.display();
1655 format!(
1664 "[Unit]\n\
1665 Description={description}\n\
1666 After=network.target\n\
1667 \n\
1668 [Service]\n\
1669 Type=simple\n\
1670 WorkingDirectory={source}\n\
1671 EnvironmentFile={home}/.env\n\
1672 Environment=SERVICE_HOME={home}\n\
1673 Environment=PATH=%h/.local/bin:%h/.cargo/bin:%h/.bun/bin:%h/.deno/bin:%h/go/bin:/usr/local/bin:/usr/bin:/bin\n\
1674 ExecStart=/bin/sh -c 'exec {run}'\n\
1675 Restart=always\n\
1676 RestartSec=5\n\
1677 \n\
1678 [Install]\n\
1679 WantedBy=default.target\n",
1680 )
1681}
1682
1683pub fn quadlet_belongs_to(filename: &str, service_name: &str, all_service_names: &[&str]) -> bool {
1692 if !filename.starts_with(service_name) {
1693 return false;
1694 }
1695 let rest = &filename[service_name.len()..];
1696 if rest.starts_with('.') {
1697 return true;
1698 }
1699 if !rest.starts_with('-') {
1700 return false;
1701 }
1702 !all_service_names.iter().any(|&other| {
1706 other.len() > service_name.len()
1707 && other.starts_with(service_name)
1708 && filename.starts_with(other)
1709 && filename[other.len()..].starts_with(['.', '-'])
1710 })
1711}
1712
1713#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
1715#[serde(rename_all = "snake_case")]
1716pub enum RemoveMode {
1717 #[default]
1718 Preserve,
1723 Purge,
1725}
1726
1727pub fn remove_service(service_name: &str, mode: RemoveMode) -> Result<RemoveResult> {
1729 let installed_owned = build_installed_from_metadata(service_name)
1732 .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
1733 let installed = &installed_owned;
1734
1735 if let Ok(Some(meta)) = metadata::load_metadata(service_name)
1740 && meta.runtime == registry::service_def::Runtime::Native
1741 {
1742 let url = installed.exposure.url().map(|s| s.to_string());
1743 return remove_native_service(service_name, mode, url);
1744 }
1745
1746 let quadlet_path = quadlet_dir()?;
1749 let mut steps = Vec::new();
1750 let mut volume_names = Vec::new();
1751 let mut networks: Vec<String> = Vec::new();
1752 let mut has_named_volumes = false;
1753 let name_pool = scan_managed_services().unwrap_or_default();
1757 let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
1758
1759 if let Some(svc_name) = installed.exposure.tailscale_svc_name() {
1771 steps.push(Step::TailscaleDisable { svc_name });
1772 }
1773
1774 if quadlet_path.is_dir()
1775 && let Ok(entries) = std::fs::read_dir(&quadlet_path)
1776 {
1777 for entry in entries.flatten() {
1778 let file_name = entry.file_name();
1779 let name = file_name.to_string_lossy();
1780 if !quadlet_belongs_to(&name, service_name, &all_names) {
1783 continue;
1784 }
1785 if name.ends_with(".container") {
1787 let unit = name.trim_end_matches(".container").to_string();
1788 steps.push(Step::StopService { unit });
1789 }
1790 if name.ends_with(".network") {
1791 let net = name.trim_end_matches(".network").to_string();
1794 steps.push(Step::StopService {
1795 unit: format!("{net}-network"),
1796 });
1797 networks.push(net);
1798 }
1799 if name.ends_with(".volume") {
1800 has_named_volumes = true;
1801 if matches!(mode, RemoveMode::Purge) {
1802 let vol = name.trim_end_matches(".volume").to_string();
1803 volume_names.push(format!("systemd-{vol}"));
1805 }
1806 }
1807 steps.push(Step::RemoveFile(entry.path()));
1808 }
1809 }
1810
1811 let had_caddy_route = matches!(
1818 installed.exposure,
1819 Exposure::Internal { .. } | Exposure::Public { .. }
1820 );
1821 if !WellKnownService::Caddy.matches(service_name) && had_caddy_route {
1822 let caddyfile_path = caddy::caddyfile_path()?;
1823 if caddyfile_path.exists() {
1824 let existing =
1825 std::fs::read_to_string(&caddyfile_path).map_err(|source| Error::FileRead {
1826 path: caddyfile_path.clone(),
1827 source,
1828 })?;
1829 let updated = caddy::remove_route(&existing, service_name);
1830 if updated != existing {
1831 steps.push(Step::WriteFile(GeneratedFile {
1832 path: caddyfile_path,
1833 content: updated.clone(),
1834 }));
1835 if !updated.trim().is_empty() {
1838 steps.push(Step::ReloadCaddy);
1839 }
1840 }
1841 }
1842 }
1843
1844 if !WellKnownService::Authelia.matches(service_name)
1845 && matches!(
1846 installed.auth_kind,
1847 Some(registry::service_def::AuthKind::Oidc)
1848 )
1849 {
1850 steps.extend(authelia::unregister_oidc_client(service_name)?);
1851 }
1852
1853 let installed_all = list_installed().unwrap_or_default();
1859 for store in installed_all
1860 .iter()
1861 .filter(|s| installed_provides(s, Capability::MetricsStore))
1862 {
1863 if store.name != service_name
1864 && let Ok(target) = metrics_bridge::target_file_path(&store.name, service_name)
1865 && target.exists()
1866 {
1867 steps.push(Step::RemoveFile(target));
1868 }
1869 }
1870 if installed.provides.contains(&Capability::MetricsStore) {
1871 for dash in installed_all
1872 .iter()
1873 .filter(|s| installed_provides(s, Capability::MetricsDashboard))
1874 {
1875 if dash.name == service_name {
1876 continue;
1877 }
1878 if let Ok(ds) = metrics_bridge::datasource_file_path(&dash.name, service_name)
1879 && ds.exists()
1880 {
1881 steps.push(Step::RemoveFile(ds));
1882 steps.push(Step::RestartService {
1883 unit: dash.name.clone(),
1884 });
1885 }
1886 }
1887 }
1888
1889 steps.push(Step::DaemonReload);
1891
1892 for net in networks {
1900 steps.push(Step::RemoveNetwork { name: net });
1901 }
1902
1903 match mode {
1904 RemoveMode::Purge => {
1905 for vol_name in volume_names {
1907 steps.push(Step::RemoveVolume { name: vol_name });
1908 }
1909 steps.push(Step::RemoveDir(service_home(service_name)?));
1911 }
1912 RemoveMode::Preserve => {
1913 let home = service_home(service_name)?;
1917 let (data, ephemeral) = crate::data::classify::classify_home_dir(&home)?;
1918 for path in ephemeral {
1919 match std::fs::metadata(&path) {
1920 Ok(m) if m.is_dir() => steps.push(Step::RemoveDir(path)),
1921 Ok(_) => steps.push(Step::RemoveFile(path)),
1922 Err(_) => steps.push(Step::RemoveFile(path)),
1926 }
1927 }
1928 if data.is_empty() && !has_named_volumes && home.exists() {
1936 steps.push(Step::RemoveDir(home));
1937 }
1938 }
1939 }
1940
1941 let url = installed.exposure.url().map(|s| s.to_string());
1942
1943 Ok(RemoveResult {
1944 steps,
1945 service_name: service_name.to_string(),
1946 url,
1947 })
1948}
1949
1950fn remove_native_service(
1955 service_name: &str,
1956 mode: RemoveMode,
1957 url: Option<String>,
1958) -> Result<RemoveResult> {
1959 let home = service_home(service_name)?;
1960 let unit_dir = systemd_user_dir()?;
1964 let unit_names: Vec<String> = [
1965 format!("{service_name}.service"),
1966 format!("{service_name}-blue.service"),
1967 format!("{service_name}-green.service"),
1968 ]
1969 .into_iter()
1970 .filter(|u| unit_dir.join(u).exists())
1971 .collect();
1972 let mut steps = Vec::new();
1973
1974 let mut aux_container_files: Vec<String> = Vec::new();
1980 if let Ok(qdir) = quadlet_dir() {
1981 let names = scan_managed_services().unwrap_or_default();
1982 let all: Vec<&str> = names.iter().map(|s| s.as_str()).collect();
1983 if let Ok(entries) = std::fs::read_dir(&qdir) {
1984 for entry in entries.flatten() {
1985 let fname = entry.file_name().to_string_lossy().into_owned();
1986 if let Some(stem) = fname.strip_suffix(".container")
1987 && quadlet_belongs_to(&fname, service_name, &all)
1988 {
1989 steps.push(Step::StopService {
1990 unit: stem.to_string(),
1991 });
1992 steps.push(Step::RemoveFile(qdir.join(&fname)));
1993 aux_container_files.push(fname);
1994 }
1995 }
1996 }
1997 }
1998
1999 for unit_name in &unit_names {
2000 steps.push(Step::StopService {
2001 unit: unit_name.trim_end_matches(".service").to_string(),
2002 });
2003 steps.push(Step::RemoveFile(unit_dir.join(unit_name)));
2004 }
2005 steps.push(Step::DaemonReload);
2006
2007 match mode {
2008 RemoveMode::Purge => steps.push(Step::RemoveDir(home)),
2009 RemoveMode::Preserve => {
2010 let mut ephemeral: Vec<String> = vec!["bin".into(), ".env".into(), "colors".into()];
2015 ephemeral.extend(unit_names.iter().cloned());
2016 for child in &ephemeral {
2017 let p = home.join(child);
2018 match std::fs::metadata(&p) {
2019 Ok(m) if m.is_dir() => steps.push(Step::RemoveDir(p)),
2020 Ok(_) => steps.push(Step::RemoveFile(p)),
2021 Err(_) => {} }
2023 }
2024 for f in &aux_container_files {
2025 steps.push(Step::RemoveFile(home.join(f)));
2026 }
2027 }
2028 }
2029
2030 Ok(RemoveResult {
2031 steps,
2032 service_name: service_name.to_string(),
2033 url,
2034 })
2035}
2036
2037#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2039#[serde(rename_all = "snake_case")]
2040pub enum Lifecycle {
2041 Start,
2042 Stop,
2043}
2044
2045pub fn lifecycle_steps(service_name: &str, action: Lifecycle) -> Result<Vec<Step>> {
2054 build_installed_from_metadata(service_name)
2056 .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
2057
2058 if matches!(
2061 metadata::load_metadata(service_name),
2062 Ok(Some(m)) if m.runtime == registry::service_def::Runtime::Native
2063 ) {
2064 let unit = service_name.to_string();
2065 return Ok(vec![match action {
2066 Lifecycle::Start => Step::StartService { unit },
2067 Lifecycle::Stop => Step::StopService { unit },
2068 }]);
2069 }
2070
2071 let mut units = service_container_units(service_name)?;
2072 match action {
2073 Lifecycle::Stop => units.sort_by_key(|u| u != service_name),
2075 Lifecycle::Start => units.sort_by_key(|u| u == service_name),
2077 }
2078
2079 Ok(units
2080 .into_iter()
2081 .map(|unit| match action {
2082 Lifecycle::Start => Step::StartService { unit },
2083 Lifecycle::Stop => Step::StopService { unit },
2084 })
2085 .collect())
2086}
2087
2088fn service_container_units(service_name: &str) -> Result<Vec<String>> {
2092 let quadlet_path = quadlet_dir()?;
2093 let name_pool = scan_managed_services().unwrap_or_default();
2094 let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
2095
2096 let mut units = Vec::new();
2097 if quadlet_path.is_dir()
2098 && let Ok(entries) = std::fs::read_dir(&quadlet_path)
2099 {
2100 for entry in entries.flatten() {
2101 let file_name = entry.file_name();
2102 let name = file_name.to_string_lossy();
2103 if !quadlet_belongs_to(&name, service_name, &all_names) {
2104 continue;
2105 }
2106 if name.ends_with(".container") {
2107 units.push(name.trim_end_matches(".container").to_string());
2108 }
2109 }
2110 }
2111 Ok(units)
2112}
2113
2114pub struct RecordPendingParams<'a> {
2116 pub service_name: &'a str,
2117 pub auth_kind: Option<registry::service_def::AuthKind>,
2118 pub registry_name: &'a str,
2119 pub allocated_ports: &'a [(String, u16)],
2120 pub repo_dir: &'a Path,
2121 pub exposure: &'a Exposure,
2128}
2129
2130pub fn record_pending(params: RecordPendingParams<'_>) -> Result<()> {
2137 let paths = ConfigPaths::resolve()?;
2138 paths.ensure_dirs()?;
2139 let mut config = config::load_or_default(&paths.config_file)?;
2140
2141 if WellKnownService::Authelia.matches(params.service_name) {
2146 config.auth = Some(authelia::auth_config(
2147 params.allocated_ports,
2148 params.exposure.url(),
2149 )?);
2150 config::save_config(&paths.config_file, &config)?;
2151 }
2152
2153 Ok(())
2154}
2155
2156pub fn finalize_remove(service_name: &str) -> Result<()> {
2163 let paths = ConfigPaths::resolve()?;
2164 let mut config = config::load_or_default(&paths.config_file)?;
2165
2166 if WellKnownService::Authelia.matches(service_name)
2167 && let Some(auth) = &config.auth
2168 && auth.provider_name() == "authelia"
2169 {
2170 config.auth = None;
2171 config::save_config(&paths.config_file, &config)?;
2172 }
2173
2174 Ok(())
2175}
2176
2177const SENSITIVE_TEMPLATE_REFS: &[&str] = &[
2196 "{{secret.",
2197 "{{auth.client_id",
2198 "{{auth.client_secret",
2199 "{{smtp.username",
2200 "{{smtp.password",
2201];
2202
2203fn is_static_template(value: &str) -> bool {
2204 !SENSITIVE_TEMPLATE_REFS.iter().any(|s| value.contains(s))
2205}
2206
2207fn collect_static_envs(
2223 service_def: ®istry::service_def::ServiceDef,
2224 ctx: &BTreeMap<String, String>,
2225 enabled_groups: &std::collections::BTreeSet<String>,
2226 selected_choices: &BTreeMap<String, String>,
2227) -> Result<Vec<plan::TrackedEnv>> {
2228 use registry::service_def::EnvKind;
2229 let mut out: Vec<plan::TrackedEnv> = Vec::new();
2230 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
2231 let push = |name: &str,
2232 value_template: &str,
2233 kind: EnvKind,
2234 prompt: Option<String>,
2235 out: &mut Vec<plan::TrackedEnv>,
2236 seen: &mut std::collections::HashSet<String>|
2237 -> Result<()> {
2238 if !is_static_template(value_template) {
2239 return Ok(());
2240 }
2241 if !seen.insert(name.to_string()) {
2242 return Ok(());
2243 }
2244 let value = generate::template::render(value_template, ctx)?;
2245 out.push(plan::TrackedEnv {
2246 key: name.to_string(),
2247 value,
2248 kind,
2249 prompt,
2250 });
2251 Ok(())
2252 };
2253 for env in &service_def.env {
2254 push(
2255 &env.name,
2256 &env.value,
2257 env.kind.clone(),
2258 env.prompt.clone(),
2259 &mut out,
2260 &mut seen,
2261 )?;
2262 }
2263 for group in &service_def.env_groups {
2264 if !enabled_groups.contains(&group.name) {
2265 continue;
2266 }
2267 for env in &group.env {
2268 push(
2269 &env.name,
2270 &env.value,
2271 env.kind.clone(),
2272 env.prompt.clone(),
2273 &mut out,
2274 &mut seen,
2275 )?;
2276 }
2277 }
2278 for choice in &service_def.choices {
2281 let selected = selected_choices
2282 .get(&choice.name)
2283 .unwrap_or(&choice.default);
2284 let Some(option) = choice.options.iter().find(|o| &o.name == selected) else {
2285 continue;
2286 };
2287 for env in &option.env {
2288 push(
2289 &env.name,
2290 &env.value,
2291 env.kind.clone(),
2292 env.prompt.clone(),
2293 &mut out,
2294 &mut seen,
2295 )?;
2296 }
2297 }
2298 if service_def.integrations.smtp && ctx.contains_key("smtp.host") {
2304 for (env_name, value_template) in &service_def.mappings.smtp {
2305 push(
2306 env_name,
2307 value_template,
2308 EnvKind::Default,
2309 None,
2310 &mut out,
2311 &mut seen,
2312 )?;
2313 }
2314 }
2315 if ctx.contains_key("auth.client_id") {
2316 for (env_name, value_template) in &service_def.mappings.auth {
2317 push(
2318 env_name,
2319 value_template,
2320 EnvKind::Default,
2321 None,
2322 &mut out,
2323 &mut seen,
2324 )?;
2325 }
2326 }
2327 Ok(out)
2328}
2329
2330pub fn orphan_purge_steps(svc: &data::ServiceData) -> Vec<Step> {
2331 let mut steps = Vec::new();
2332
2333 let mut had_quadlet = false;
2339 let mut networks: Vec<String> = Vec::new();
2340 if let Ok(qdir) = quadlet_dir()
2341 && qdir.is_dir()
2342 && let Ok(entries) = std::fs::read_dir(&qdir)
2343 {
2344 let name_pool = scan_managed_services().unwrap_or_default();
2345 let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
2346 for entry in entries.flatten() {
2347 let file_name = entry.file_name();
2348 let name = file_name.to_string_lossy();
2349 if !quadlet_belongs_to(&name, &svc.service, &all_names) {
2350 continue;
2351 }
2352 if name.ends_with(".container") {
2356 let unit = name.trim_end_matches(".container").to_string();
2357 steps.push(Step::StopService { unit });
2358 } else if name.ends_with(".network") {
2359 let net = name.trim_end_matches(".network").to_string();
2360 steps.push(Step::StopService {
2361 unit: format!("{net}-network"),
2362 });
2363 networks.push(net);
2364 } else if name.ends_with(".volume") {
2365 let unit = format!("{}-volume", name.trim_end_matches(".volume"));
2366 steps.push(Step::StopService { unit });
2367 }
2368 steps.push(Step::RemoveFile(entry.path()));
2369 had_quadlet = true;
2370 }
2371 }
2372 if had_quadlet {
2373 steps.push(Step::DaemonReload);
2374 }
2375 for net in networks {
2377 steps.push(Step::RemoveNetwork { name: net });
2378 }
2379
2380 for path in &svc.data_paths {
2381 if path.is_dir() {
2382 steps.push(Step::RemoveDir(path.clone()));
2383 } else {
2384 steps.push(Step::RemoveFile(path.clone()));
2385 }
2386 }
2387 if svc.home_dir.exists() {
2388 steps.push(Step::RemoveDir(svc.home_dir.clone()));
2389 }
2390 for v in &svc.volumes {
2391 steps.push(Step::RemoveVolume {
2392 name: v.name.clone(),
2393 });
2394 }
2395 steps
2396}
2397
2398pub fn reset() -> Result<ResetResult> {
2400 let mut steps = Vec::new();
2401
2402 let managed_names = scan_managed_services().unwrap_or_default();
2407
2408 for svc in list_installed().unwrap_or_default() {
2415 if let Some(svc_name) = svc.exposure.tailscale_svc_name() {
2416 steps.push(Step::TailscaleDisable { svc_name });
2417 }
2418 }
2419
2420 let quadlet_path = quadlet_dir()?;
2422 let all_names: Vec<&str> = managed_names.iter().map(|s| s.as_str()).collect();
2423 let mut networks: Vec<String> = Vec::new();
2424 if quadlet_path.is_dir()
2425 && let Ok(entries) = std::fs::read_dir(&quadlet_path)
2426 {
2427 for entry in entries.flatten() {
2428 let file_name = entry.file_name();
2429 let name = file_name.to_string_lossy();
2430 let is_ryra_file = managed_names
2434 .iter()
2435 .any(|svc| quadlet_belongs_to(&name, svc, &all_names));
2436 if !is_ryra_file {
2437 continue;
2438 }
2439 if name.ends_with(".container") {
2440 let unit = name.trim_end_matches(".container").to_string();
2441 steps.push(Step::StopService { unit });
2442 }
2443 if name.ends_with(".network") {
2444 let net = name.trim_end_matches(".network").to_string();
2445 steps.push(Step::StopService {
2446 unit: format!("{net}-network"),
2447 });
2448 networks.push(net);
2449 }
2450 if name.ends_with(".volume") {
2451 let vol = name.trim_end_matches(".volume").to_string();
2452 steps.push(Step::StopService {
2459 unit: format!("{vol}-volume"),
2460 });
2461 }
2462 steps.push(Step::RemoveFile(entry.path()));
2463 }
2464 }
2465
2466 let user_unit_dir = systemd_user_dir()?;
2472 if let Ok(root) = service_data_root()
2473 && let Ok(entries) = std::fs::read_dir(&root)
2474 {
2475 for entry in entries.flatten() {
2476 let Some(name) = entry.file_name().to_str().map(str::to_string) else {
2477 continue;
2478 };
2479 if matches!(
2480 metadata::load_metadata(&name),
2481 Ok(Some(m)) if m.runtime == registry::service_def::Runtime::Native
2482 ) {
2483 steps.push(Step::StopService { unit: name.clone() });
2484 steps.push(Step::RemoveFile(
2485 user_unit_dir.join(format!("{name}.service")),
2486 ));
2487 }
2488 }
2489 }
2490
2491 steps.push(Step::DaemonReload);
2493
2494 for net in networks {
2497 steps.push(Step::RemoveNetwork { name: net });
2498 }
2499
2500 let mut seen_volumes = std::collections::BTreeSet::new();
2506 for svc in data::enumerate_all().unwrap_or_default() {
2507 for vol in svc.volumes {
2508 if seen_volumes.insert(vol.name.clone()) {
2509 steps.push(Step::RemoveVolume { name: vol.name });
2510 }
2511 }
2512 }
2513
2514 let data_root = service_data_root()?;
2520 if data_root.exists() {
2521 steps.push(Step::RemoveDir(data_root));
2522 }
2523
2524 Ok(ResetResult { steps })
2525}
2526
2527pub fn finalize_reset() -> Result<()> {
2529 let paths = ConfigPaths::resolve()?;
2530 if paths.config_dir.exists() {
2531 std::fs::remove_dir_all(&paths.config_dir).map_err(|source| Error::FileWrite {
2532 path: paths.config_dir,
2533 source,
2534 })?;
2535 }
2536 Ok(())
2537}
2538
2539pub fn status() -> config::status::RyraStatus {
2545 let paths = match ConfigPaths::resolve() {
2546 Ok(p) => p,
2547 Err(_) => return config::status::RyraStatus::NotInitialized,
2548 };
2549
2550 let has_quadlets = scan_managed_services()
2551 .map(|n| !n.is_empty())
2552 .unwrap_or(false);
2553
2554 let config = match config::load_config(&paths.config_file) {
2555 Ok(c) => c,
2556 Err(Error::ConfigNotFound(_)) if has_quadlets => config::schema::Config::default(),
2557 Err(Error::ConfigNotFound(_)) => return config::status::RyraStatus::NotInitialized,
2558 Err(e) => return config::status::RyraStatus::Error(e.to_string()),
2559 };
2560
2561 config::status::RyraStatus::Initialized(config::status::StatusInfo::from_config(
2562 paths.config_file,
2563 &config,
2564 ))
2565}
2566
2567pub fn is_service_installed(name: &str) -> bool {
2575 let Ok(Some(meta)) = metadata::load_metadata(name) else {
2579 return false;
2580 };
2581 match meta.runtime {
2582 registry::service_def::Runtime::Native => systemd_user_dir()
2585 .map(|d| {
2586 d.join(format!("{name}.service")).exists()
2587 || d.join(format!("{name}-blue.service")).exists()
2588 || d.join(format!("{name}-green.service")).exists()
2589 })
2590 .unwrap_or(false),
2591 registry::service_def::Runtime::Podman => scan_managed_services()
2592 .map(|names| names.iter().any(|n| n == name))
2593 .unwrap_or(false),
2594 }
2595}
2596
2597pub fn scan_managed_services() -> Result<Vec<String>> {
2610 let dir = match quadlet_dir() {
2611 Ok(d) => d,
2612 Err(_) => return Ok(Vec::new()),
2613 };
2614 let entries = match std::fs::read_dir(&dir) {
2615 Ok(e) => e,
2616 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
2617 Err(source) => return Err(Error::FileRead { path: dir, source }),
2618 };
2619 let mut names: Vec<String> = Vec::new();
2620 for entry in entries.flatten() {
2621 let path = entry.path();
2622 if path.extension().and_then(|e| e.to_str()) != Some("container") {
2623 continue;
2624 }
2625 let Ok(content) = std::fs::read_to_string(&path) else {
2626 continue;
2627 };
2628 for line in content.lines().take(16) {
2629 if let Some(rest) = line.trim().strip_prefix("# Service-Source: registry/")
2630 && !rest.is_empty()
2631 && !names.iter().any(|n| n == rest)
2632 {
2633 names.push(rest.to_string());
2634 break;
2635 }
2636 }
2637 }
2638 names.sort();
2639 Ok(names)
2640}
2641
2642fn build_installed_from_metadata(service_name: &str) -> Option<InstalledService> {
2648 let meta = load_metadata(service_name).ok().flatten()?;
2649
2650 let exposure = match meta.url.as_deref() {
2652 None => Exposure::Loopback,
2653 Some(u) => Exposure::from_url(u),
2654 };
2655
2656 let auth_kind = meta.auth.clone();
2657
2658 let ports = service_home(service_name)
2664 .ok()
2665 .and_then(|home| std::fs::read_to_string(home.join(".env")).ok())
2666 .map(|env| {
2667 env.lines()
2668 .filter_map(|l| {
2669 let l = l.trim();
2670 if l.is_empty() || l.starts_with('#') {
2671 return None;
2672 }
2673 let (key, val) = l.split_once('=')?;
2674 let name = key.strip_prefix("SERVICE_PORT_")?.to_lowercase();
2675 let port = val
2676 .trim_matches(|c: char| c == '"' || c == '\'')
2677 .parse::<u16>()
2678 .ok()?;
2679 Some((name, port))
2680 })
2681 .collect::<std::collections::BTreeMap<String, u16>>()
2682 })
2683 .unwrap_or_default();
2684
2685 Some(InstalledService {
2686 name: service_name.to_string(),
2687 version: "0.1.0".to_string(),
2688 repo: meta.registry,
2689 ports,
2690 auth_kind,
2691 exposure,
2692 provides: meta.provides,
2693 installed: true,
2694 })
2695}
2696
2697pub fn list_installed() -> Result<Vec<InstalledService>> {
2704 let mut names: std::collections::BTreeSet<String> = scan_managed_services()
2705 .unwrap_or_default()
2706 .into_iter()
2707 .collect();
2708 if let Ok(root) = service_data_root()
2712 && let Ok(entries) = std::fs::read_dir(&root)
2713 {
2714 for entry in entries.flatten() {
2715 if let Some(name) = entry.file_name().to_str()
2716 && !names.contains(name)
2717 && is_service_installed(name)
2718 {
2719 names.insert(name.to_string());
2720 }
2721 }
2722 }
2723 let out: Vec<InstalledService> = names
2724 .iter()
2725 .filter_map(|n| build_installed_from_metadata(n))
2726 .collect();
2727 Ok(out)
2728}
2729
2730pub fn search_services(repo_dir: &Path, query: Option<&str>) -> Result<Vec<SearchResult>> {
2732 let available = registry::list_available(repo_dir)?;
2733
2734 let results = available
2735 .into_iter()
2736 .filter(|reg_svc| match query {
2737 None => true,
2738 Some(q) => {
2739 let q = q.to_lowercase();
2740 reg_svc.def.service.name.to_lowercase().contains(&q)
2741 || reg_svc.def.service.description.to_lowercase().contains(&q)
2742 }
2743 })
2744 .map(|reg_svc| {
2745 let name = ®_svc.def.service.name;
2746 let installed = is_service_installed(name);
2747 let mut supports = Vec::new();
2748 for kind in ®_svc.def.integrations.auth {
2749 supports.push(kind.to_string());
2750 }
2751 if reg_svc.def.integrations.smtp {
2752 supports.push("smtp".to_string());
2753 }
2754 SearchResult {
2755 name: name.clone(),
2756 description: reg_svc.def.service.description,
2757 installed,
2758 supports,
2759 }
2760 })
2761 .collect();
2762
2763 Ok(results)
2764}
2765
2766pub struct SearchResult {
2767 pub name: String,
2768 pub description: String,
2769 pub installed: bool,
2770 pub supports: Vec<String>,
2772}
2773
2774pub async fn service_tests(service_name: &str) -> Result<ServiceTestInfo> {
2776 let installed = build_installed_from_metadata(service_name)
2777 .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
2778
2779 let service_ref = service_ref_from_installed(&installed);
2780 let repo_dir = resolve_registry_dir(&service_ref).await?;
2781
2782 let test_toml_path = repo_dir.join(service_name).join("test.toml");
2783 let env_file = service_home(service_name)?.join(".env");
2784
2785 if !test_toml_path.exists() {
2786 return Ok(ServiceTestInfo {
2787 service_name: service_name.to_string(),
2788 registry_name: service_ref.registry_name().to_string(),
2789 tests: vec![],
2790 env_file,
2791 });
2792 }
2793
2794 let content = std::fs::read_to_string(&test_toml_path).map_err(|source| Error::FileRead {
2795 path: test_toml_path.clone(),
2796 source,
2797 })?;
2798
2799 #[derive(serde::Deserialize)]
2800 struct TestFile {
2801 #[serde(default)]
2802 tests: Vec<registry::test_def::TestDef>,
2803 }
2804
2805 let parsed: TestFile = toml::from_str(&content).map_err(|source| Error::TomlParse {
2806 path: test_toml_path,
2807 source,
2808 })?;
2809
2810 Ok(ServiceTestInfo {
2811 service_name: service_name.to_string(),
2812 registry_name: service_ref.registry_name().to_string(),
2813 tests: parsed.tests,
2814 env_file,
2815 })
2816}
2817
2818pub struct ServiceTestInfo {
2819 pub service_name: String,
2820 pub registry_name: String,
2821 pub tests: Vec<registry::test_def::TestDef>,
2822 pub env_file: PathBuf,
2823}
2824
2825pub fn service_info(repo_dir: &Path, service_name: &str) -> Result<ServiceDetail> {
2827 let reg_service = registry::find_service(repo_dir, service_name)?;
2828 let def = ®_service.def;
2829
2830 Ok(ServiceDetail {
2831 name: def.service.name.clone(),
2832 description: def.service.description.clone(),
2833 url: def.service.url.clone(),
2834 ports: def
2835 .ports
2836 .iter()
2837 .map(|p| (p.container_port, p.protocol.clone(), p.name.clone()))
2838 .collect(),
2839 env_vars: def
2840 .env
2841 .iter()
2842 .map(|e| (e.name.clone(), e.prompt.clone()))
2843 .collect(),
2844 })
2845}
2846
2847pub struct ServiceDetail {
2848 pub name: String,
2849 pub description: String,
2850 pub url: Option<String>,
2851 pub ports: Vec<(u16, registry::service_def::PortProtocol, String)>,
2852 pub env_vars: Vec<(String, Option<String>)>,
2853}
2854
2855#[cfg(test)]
2856mod tests {
2857 use super::*;
2858
2859 fn write_demo_registry(tmp: &std::path::Path, deploy_line: &str) {
2864 let svc_dir = tmp.join("demo");
2865 std::fs::create_dir_all(svc_dir.join("quadlets")).unwrap();
2866 std::fs::write(
2867 svc_dir.join("service.toml"),
2868 format!(
2869 "[service]\n\
2870 name = \"demo\"\n\
2871 description = \"demo\"\n\
2872 runtime = \"podman\"\n\
2873 {deploy_line}\n\
2874 \n\
2875 [[ports]]\n\
2876 name = \"http\"\n\
2877 container_port = 8080\n"
2878 ),
2879 )
2880 .unwrap();
2881 std::fs::write(
2882 svc_dir.join("quadlets").join("demo.container"),
2883 "[Container]\n\
2884 Image=docker.io/traefik/whoami:latest\n\
2885 ContainerName=demo\n\
2886 PublishPort=${SERVICE_PORT_HTTP}:8080\n\
2887 EnvironmentFile=%h/.local/share/services/demo/.env\n\
2888 \n\
2889 [Service]\n\
2890 EnvironmentFile=%h/.local/share/services/demo/.env\n\
2891 \n\
2892 [Install]\n\
2893 WantedBy=default.target\n",
2894 )
2895 .unwrap();
2896 }
2897
2898 fn write_native_registry(tmp: &std::path::Path) {
2901 let svc_dir = tmp.join("napp");
2902 std::fs::create_dir_all(&svc_dir).unwrap();
2903 std::fs::write(
2904 svc_dir.join("service.toml"),
2905 "[service]\n\
2906 name = \"napp\"\n\
2907 description = \"native demo\"\n\
2908 runtime = \"native\"\n\
2909 run = \"python -m app\"\n\
2910 build = \"pip install -r requirements.txt\"\n\
2911 deploy = \"blue-green\"\n\
2912 health_check = \"/healthz\"\n\
2913 \n\
2914 [[ports]]\n\
2915 name = \"http\"\n\
2916 container_port = 8080\n",
2917 )
2918 .unwrap();
2919 std::fs::write(svc_dir.join("app.py"), "print('hi')\n").unwrap();
2921 }
2922
2923 fn plan_demo(tmp: &std::path::Path) -> AddResult {
2924 plan_service(tmp, "demo")
2925 }
2926
2927 fn plan_service(tmp: &std::path::Path, name: &'static str) -> AddResult {
2928 plan_service_exposed(tmp, name, exposure::Exposure::Loopback)
2929 }
2930
2931 fn plan_service_exposed(
2932 tmp: &std::path::Path,
2933 name: &'static str,
2934 exposure: exposure::Exposure,
2935 ) -> AddResult {
2936 let empty_map = std::collections::BTreeMap::new();
2937 let empty_ports: std::collections::BTreeMap<String, u16> =
2938 std::collections::BTreeMap::new();
2939 let empty_set = std::collections::BTreeSet::new();
2940 let port_in_use = |_p: u16| false;
2941 add_service(AddServiceParams {
2942 service_name: name,
2943 exposure: &exposure,
2944 auth: AuthChoice::None,
2945 enable_smtp: false,
2946 enable_backup: false,
2947 env_overrides: &empty_map,
2948 enabled_groups: &empty_set,
2949 selected_choices: &empty_map,
2950 registry_name: "test",
2951 repo_dir: tmp,
2952 pre_built_ctx: None,
2953 port_in_use: &port_in_use,
2954 acme_mode: None,
2955 mode: PlanMode::Add,
2956 port_overrides: &empty_ports,
2957 })
2958 .expect("plan add")
2959 }
2960
2961 #[test]
2965 fn blue_green_podman_add_emits_two_slots_and_starts_blue() {
2966 let tmp = tempfile::tempdir().unwrap();
2967 write_demo_registry(
2968 tmp.path(),
2969 "deploy = \"blue-green\"\nhealth_check = \"/healthz\"",
2970 );
2971 let result = plan_demo(tmp.path());
2972
2973 let written: Vec<String> = result
2976 .steps
2977 .iter()
2978 .filter_map(|s| match s {
2979 Step::WriteFile(f) => f
2980 .path
2981 .file_name()
2982 .and_then(|n| n.to_str())
2983 .map(String::from),
2984 _ => None,
2985 })
2986 .collect();
2987 assert!(
2988 written.iter().any(|n| n == "demo-blue.container"),
2989 "got {written:?}"
2990 );
2991 assert!(
2992 written.iter().any(|n| n == "demo-green.container"),
2993 "got {written:?}"
2994 );
2995 assert!(
2996 !written.iter().any(|n| n == "demo.container"),
2997 "bare slot leaked: {written:?}"
2998 );
2999
3000 let blue = result
3002 .steps
3003 .iter()
3004 .find_map(|s| match s {
3005 Step::WriteFile(f) if f.path.ends_with("demo-blue.container") => Some(&f.content),
3006 _ => None,
3007 })
3008 .unwrap();
3009 assert!(blue.contains("ContainerName=demo-blue"));
3010 assert!(blue.contains("${SERVICE_PORT_HTTP_BLUE}"));
3011
3012 let started: Vec<&str> = result
3014 .steps
3015 .iter()
3016 .filter_map(|s| match s {
3017 Step::StartService { unit } => Some(unit.as_str()),
3018 _ => None,
3019 })
3020 .collect();
3021 assert!(started.contains(&"demo-blue"), "started: {started:?}");
3022 assert!(!started.contains(&"demo"), "bare unit started: {started:?}");
3023
3024 let env = result
3026 .steps
3027 .iter()
3028 .find_map(|s| match s {
3029 Step::WriteFile(f) if f.path.file_name() == Some(std::ffi::OsStr::new(".env")) => {
3030 Some(&f.content)
3031 }
3032 _ => None,
3033 })
3034 .unwrap();
3035 assert!(env.contains("SERVICE_PORT_HTTP_BLUE="), "env: {env}");
3036 assert!(env.contains("SERVICE_PORT_HTTP_GREEN="), "env: {env}");
3037 }
3038
3039 #[test]
3043 fn blue_green_native_add_syncs_builds_and_starts_blue() {
3044 let tmp = tempfile::tempdir().unwrap();
3045 write_native_registry(tmp.path());
3046 let result = plan_service(tmp.path(), "napp");
3047
3048 let syncs: Vec<String> = result
3050 .steps
3051 .iter()
3052 .filter_map(|s| match s {
3053 Step::SyncDir { dst, .. } => Some(dst.to_string_lossy().into_owned()),
3054 _ => None,
3055 })
3056 .collect();
3057 assert!(
3058 syncs.iter().any(|d| d.ends_with("colors/blue")),
3059 "syncs: {syncs:?}"
3060 );
3061 assert!(
3062 syncs.iter().any(|d| d.ends_with("colors/green")),
3063 "syncs: {syncs:?}"
3064 );
3065 let builds: Vec<String> = result
3066 .steps
3067 .iter()
3068 .filter_map(|s| match s {
3069 Step::Build { dir, .. } => Some(dir.to_string_lossy().into_owned()),
3070 _ => None,
3071 })
3072 .collect();
3073 assert!(
3074 builds.iter().any(|d| d.ends_with("colors/blue")),
3075 "builds: {builds:?}"
3076 );
3077 assert!(
3078 builds.iter().any(|d| d.ends_with("colors/green")),
3079 "builds: {builds:?}"
3080 );
3081
3082 let green_unit = result
3085 .steps
3086 .iter()
3087 .find_map(|s| match s {
3088 Step::WriteFile(f) if f.path.ends_with("napp-green.service") => Some(&f.content),
3089 _ => None,
3090 })
3091 .expect("green unit");
3092 assert!(green_unit.contains("WorkingDirectory="));
3093 assert!(green_unit.contains("colors/green"));
3094 assert!(green_unit.contains("Environment=SERVICE_PORT_HTTP="));
3095 assert!(green_unit.contains("ExecStart=/bin/sh -c 'exec python -m app'"));
3096
3097 let started: Vec<&str> = result
3099 .steps
3100 .iter()
3101 .filter_map(|s| match s {
3102 Step::StartService { unit } => Some(unit.as_str()),
3103 _ => None,
3104 })
3105 .collect();
3106 assert_eq!(started, vec!["napp-blue"], "started: {started:?}");
3107
3108 let env = result
3110 .steps
3111 .iter()
3112 .find_map(|s| match s {
3113 Step::WriteFile(f) if f.path.file_name() == Some(std::ffi::OsStr::new(".env")) => {
3114 Some(&f.content)
3115 }
3116 _ => None,
3117 })
3118 .unwrap();
3119 assert!(env.contains("SERVICE_PORT_HTTP_BLUE="));
3120 assert!(env.contains("SERVICE_PORT_HTTP_GREEN="));
3121 }
3122
3123 #[test]
3129 fn blue_green_native_add_with_url_warns_when_no_caddy() {
3130 let tmp = tempfile::tempdir().unwrap();
3131 write_native_registry(tmp.path());
3132 let result = plan_service_exposed(
3133 tmp.path(),
3134 "napp",
3135 exposure::Exposure::Public {
3136 url: "https://napp.example.com".into(),
3137 },
3138 );
3139 assert!(
3140 result
3141 .warnings
3142 .iter()
3143 .any(|w| matches!(w, Warning::UrlWithoutReverseProxy { .. })),
3144 "native + url + no caddy should warn UrlWithoutReverseProxy"
3145 );
3146 }
3147
3148 #[test]
3151 fn restart_podman_add_is_unchanged() {
3152 let tmp = tempfile::tempdir().unwrap();
3153 write_demo_registry(tmp.path(), "");
3154 let result = plan_demo(tmp.path());
3155 let written: Vec<String> = result
3156 .steps
3157 .iter()
3158 .filter_map(|s| match s {
3159 Step::WriteFile(f) => f
3160 .path
3161 .file_name()
3162 .and_then(|n| n.to_str())
3163 .map(String::from),
3164 _ => None,
3165 })
3166 .collect();
3167 assert!(
3168 written.iter().any(|n| n == "demo.container"),
3169 "got {written:?}"
3170 );
3171 assert!(
3172 !written.iter().any(|n| n.contains("-blue")),
3173 "got {written:?}"
3174 );
3175 let started: Vec<&str> = result
3176 .steps
3177 .iter()
3178 .filter_map(|s| match s {
3179 Step::StartService { unit } => Some(unit.as_str()),
3180 _ => None,
3181 })
3182 .collect();
3183 assert!(started.contains(&"demo"));
3184 }
3185
3186 #[test]
3187 fn static_template_filter_excludes_secrets_and_credentials() {
3188 assert!(is_static_template("3306"));
3190 assert!(is_static_template("mariadb"));
3191 assert!(is_static_template("{{service.port}}"));
3193 assert!(is_static_template("{{service.url}}"));
3194 assert!(is_static_template("{{auth.url}}"));
3195 assert!(is_static_template("{{auth.issuer}}"));
3196 assert!(is_static_template("{{auth.provider}}"));
3197 assert!(is_static_template("{{auth.internal_url}}"));
3198 assert!(is_static_template("{{smtp.host}}"));
3199 assert!(is_static_template("{{smtp.port}}"));
3200 assert!(is_static_template("{{smtp.from}}"));
3201 assert!(is_static_template("{{service.url}}/oauth/callback"));
3203
3204 assert!(!is_static_template("{{secret.admin_password}}"));
3206 assert!(!is_static_template("{{secret.jwt_key}}"));
3207 assert!(!is_static_template("{{auth.client_id}}"));
3209 assert!(!is_static_template("{{auth.client_secret}}"));
3210 assert!(!is_static_template("{{smtp.username}}"));
3212 assert!(!is_static_template("{{smtp.password}}"));
3213 assert!(!is_static_template(
3215 "redis://:{{secret.redis_pw}}@host:6379"
3216 ));
3217 }
3218
3219 #[test]
3220 fn tailscale_url_matches() {
3221 assert!(is_tailscale_url("http://debian.cobbler-tuna.ts.net"));
3222 assert!(is_tailscale_url("http://debian.cobbler-tuna.ts.net:10001/"));
3223 assert!(is_tailscale_url("https://foo.example-net.ts.net"));
3224 assert!(is_tailscale_url("http://HOST.COBBLER-TUNA.TS.NET"));
3225 }
3226
3227 #[test]
3228 fn tailscale_url_rejects() {
3229 assert!(!is_tailscale_url("https://nextcloud.internal:8443"));
3230 assert!(!is_tailscale_url("https://example.com"));
3231 assert!(!is_tailscale_url("http://127.0.0.1:10001"));
3232 assert!(!is_tailscale_url("https://ts.net"));
3234 assert!(!is_tailscale_url("https://evil-ts.net.example.com"));
3235 assert!(!is_tailscale_url("not a url"));
3236 }
3237
3238 #[test]
3239 fn public_url_accepts_public_domains() {
3240 assert!(is_public_url("https://seafile.ryra.no"));
3241 assert!(is_public_url("https://example.com"));
3242 assert!(is_public_url("https://docs.ryra.no:8443"));
3243 }
3244
3245 #[test]
3246 fn public_url_rejects_lan_and_tailnet() {
3247 assert!(!is_public_url("https://nextcloud.internal:8443"));
3248 assert!(!is_public_url("https://service.localhost"));
3249 assert!(!is_public_url("https://something.local"));
3250 assert!(!is_public_url("https://localhost:8080"));
3251 assert!(!is_public_url("https://debian.cobbler-tuna.ts.net"));
3252 assert!(!is_public_url("http://127.0.0.1:10001"));
3253 assert!(!is_public_url("http://192.168.1.10"));
3254 assert!(!is_public_url("http://[::1]"));
3255 assert!(!is_public_url("not a url"));
3256 }
3257
3258 #[test]
3263 fn networks_empty_when_no_auth() {
3264 let nets = resolve_extra_networks(
3265 "whoami", false, false, false, false, false, false, None, false,
3266 );
3267 assert!(nets.is_empty());
3268 }
3269
3270 #[test]
3271 fn networks_empty_when_auth_but_no_authelia() {
3272 let nets = resolve_extra_networks(
3273 "forgejo", true, false, false, false, false, false, None, false,
3274 );
3275 assert!(nets.is_empty());
3276 }
3277
3278 #[test]
3279 fn networks_authelia_when_auth_enabled() {
3280 let nets = resolve_extra_networks(
3281 "forgejo", true, true, false, false, false, false, None, false,
3282 );
3283 assert_eq!(nets, vec!["authelia"]);
3284 }
3285
3286 #[test]
3287 fn networks_auth_with_caddy_includes_both() {
3288 let nets = resolve_extra_networks(
3289 "forgejo", true, true, true, false, false, false, None, false,
3290 );
3291 assert!(nets.contains(&"authelia".to_string()));
3292 assert!(nets.contains(&"caddy".to_string()));
3293 }
3294
3295 #[test]
3296 fn networks_authelia_excluded_for_authelia_itself() {
3297 let nets = resolve_extra_networks(
3298 "authelia", true, true, false, false, false, false, None, false,
3299 );
3300 assert!(nets.is_empty());
3301 }
3302
3303 #[test]
3304 fn networks_smtp_joins_inbucket_without_caddy() {
3305 let nets = resolve_extra_networks(
3307 "forgejo", false, false, false, true, false, true, None, false,
3308 );
3309 assert_eq!(nets, vec!["inbucket"]);
3310 }
3311
3312 #[test]
3313 fn networks_smtp_skips_inbucket_when_it_is_self() {
3314 let nets = resolve_extra_networks(
3315 "inbucket", false, false, false, true, false, true, None, false,
3316 );
3317 assert!(!nets.contains(&"inbucket".to_string()));
3318 }
3319
3320 #[test]
3321 fn networks_smtp_skips_inbucket_when_not_installed() {
3322 let nets = resolve_extra_networks(
3323 "forgejo", false, false, false, false, false, true, None, false,
3324 );
3325 assert!(!nets.contains(&"inbucket".to_string()));
3326 }
3327
3328 #[test]
3329 fn networks_metrics_consumer_joins_store() {
3330 let nets = resolve_extra_networks(
3331 "grafana",
3332 false,
3333 false,
3334 false,
3335 false,
3336 false,
3337 false,
3338 Some("prometheus"),
3339 true,
3340 );
3341 assert_eq!(nets, vec!["prometheus".to_string()]);
3342 }
3343
3344 #[test]
3345 fn networks_metrics_store_skips_itself() {
3346 let nets = resolve_extra_networks(
3347 "prometheus",
3348 false,
3349 false,
3350 false,
3351 false,
3352 false,
3353 false,
3354 Some("prometheus"),
3355 true,
3356 );
3357 assert!(nets.is_empty());
3358 }
3359
3360 #[test]
3361 fn networks_metrics_indifferent_service_skips_store() {
3362 let nets = resolve_extra_networks(
3363 "vaultwarden",
3364 false,
3365 false,
3366 false,
3367 false,
3368 false,
3369 false,
3370 Some("prometheus"),
3371 false,
3372 );
3373 assert!(nets.is_empty());
3374 }
3375
3376 #[test]
3377 fn quadlet_belongs_to_exact_match() {
3378 let all = &["foo", "foo-bar"];
3379 assert!(quadlet_belongs_to("foo.container", "foo", all));
3380 assert!(quadlet_belongs_to("foo.network", "foo", all));
3381 }
3382
3383 #[test]
3384 fn quadlet_belongs_to_sidecar() {
3385 let all = &["foo"];
3387 assert!(quadlet_belongs_to("foo-db.volume", "foo", all));
3388 }
3389
3390 #[test]
3391 fn quadlet_belongs_to_rejects_prefix_collision() {
3392 let all = &["foo", "foo-bar"];
3393 assert!(!quadlet_belongs_to("foo-bar.container", "foo", all));
3394 assert!(!quadlet_belongs_to("foo-bar-db.volume", "foo", all));
3395 }
3396
3397 #[test]
3398 fn quadlet_belongs_to_hyphenated_service() {
3399 let all = &["foo", "foo-bar"];
3400 assert!(quadlet_belongs_to("foo-bar.container", "foo-bar", all));
3401 assert!(quadlet_belongs_to("foo-bar-db.volume", "foo-bar", all));
3402 assert!(!quadlet_belongs_to("foo.container", "foo-bar", all));
3403 }
3404}