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 error;
10pub mod exposure;
11pub mod generate;
12pub mod manifest;
13pub mod metadata;
14pub mod metrics_bridge;
15pub mod ops;
16pub mod paths;
17pub mod plan;
18pub mod registry;
19pub mod system;
20pub mod upgrade;
21pub mod well_known;
22
23use std::collections::BTreeMap;
24use std::path::{Path, PathBuf};
25
26use config::ConfigPaths;
27use config::schema::InstalledService;
28use error::{Error, Result};
29
30pub use capability::{
31 Capability, any_installed_provider, find_installed_provider, installed_provides,
32 service_provides,
33};
34pub use configure::{
35 ConfigureChange, ConfigureResult, ExposureChange, Overrides as ConfigureOverrides,
36 configure_service,
37};
38pub use exposure::{
39 Exposure, check_auth_exposure_compat, is_caddy_local_url, is_public_url, is_tailscale_url,
40};
41pub use generate::GeneratedFile;
42pub use manifest::{ManifestEntry, manifest_path};
43pub use metadata::{Metadata, load_metadata};
44pub use paths::{
45 CONFIG_DIR_ENV, DATA_DIR_ENV, DEFAULT_REGISTRY_URL, REGISTRY_DEFAULT, REGISTRY_DIR_ENV,
46 metadata_path, quadlet_dir, service_data_root, service_home, systemd_user_dir,
47};
48pub use plan::{AddResult, RemoveResult, ResetResult, Step, TailscalePort, TrackedEnv, Warning};
49pub use upgrade::{
50 BackupSnapshot, DEFAULT_BACKUP_KEEP, DiffEntry, DiffKind, DiffResult, EnvAddition,
51 RevertResult, UpgradeResult, diff_service, list_backups, prune_backups, revert_service,
52 upgrade_service,
53};
54pub use well_known::WellKnownService;
55
56pub(crate) use paths::home_dir;
57pub(crate) use well_known::caddy_https_port;
58
59pub async fn resolve_registry_dir(service_ref: ®istry::resolve::ServiceRef) -> Result<PathBuf> {
61 let paths = ConfigPaths::resolve()?;
62 paths.ensure_cache_dir()?;
63 let config = config::load_or_default(&paths.config_file)?;
64 registry::resolve::resolve_registry_dir(service_ref, &config, &paths.cache_dir).await
65}
66
67pub fn service_ref_from_installed(installed: &InstalledService) -> registry::resolve::ServiceRef {
69 if installed.repo.is_empty() || installed.repo == REGISTRY_DEFAULT {
70 registry::resolve::ServiceRef::Default(installed.name.clone())
71 } else {
72 registry::resolve::ServiceRef::Custom {
73 registry: installed.repo.clone(),
74 service: installed.name.clone(),
75 }
76 }
77}
78
79fn retroactive_network_joins(
88 new_service: &str,
89 quadlet_path: &std::path::Path,
90 _repo_dir: Option<&std::path::Path>,
91) -> Vec<Step> {
92 let mut steps = Vec::new();
93 let new_cap = if service_provides(new_service, Capability::ReverseProxy) {
98 Capability::ReverseProxy
99 } else if service_provides(new_service, Capability::SmtpRelay) {
100 Capability::SmtpRelay
101 } else {
102 return steps;
103 };
104
105 let installed = list_installed().unwrap_or_default();
106 for svc in &installed {
107 if !svc.provides.is_empty() {
110 continue;
111 }
112 let (network_name, should_join) = match new_cap {
113 Capability::ReverseProxy => {
114 let wants_proxy = matches!(
118 svc.exposure,
119 Exposure::Internal { .. } | Exposure::Public { .. }
120 );
121 (new_service.to_string(), wants_proxy)
122 }
123 Capability::SmtpRelay => {
124 (
127 new_service.to_string(),
128 service_uses_smtp_relay(&svc.name, new_service),
129 )
130 }
131 Capability::OidcProvider
137 | Capability::ForwardAuthProvider
138 | Capability::MetricsStore
139 | Capability::MetricsDashboard => {
140 continue;
141 }
142 };
143 if !should_join {
144 continue;
145 }
146 let installed_names_owned: Vec<String> = installed.iter().map(|s| s.name.clone()).collect();
147 let all_service_names: Vec<&str> =
148 installed_names_owned.iter().map(|s| s.as_str()).collect();
149 steps.extend(network_join_steps(
150 &svc.name,
151 &network_name,
152 quadlet_path,
153 &all_service_names,
154 ));
155 }
156 steps
157}
158
159fn network_join_steps(
169 svc_name: &str,
170 network_name: &str,
171 quadlet_path: &std::path::Path,
172 all_service_names: &[&str],
173) -> Vec<Step> {
174 let mut steps = Vec::new();
175 let marker = format!("Network={network_name}.network");
176 let mut units_to_restart: Vec<String> = Vec::new();
177 let Ok(entries) = std::fs::read_dir(quadlet_path) else {
178 return steps;
179 };
180 for entry in entries.flatten() {
181 let path = entry.path();
182 let name = match path.file_name().and_then(|n| n.to_str()) {
183 Some(n) if n.ends_with(".container") => n.to_string(),
184 _ => continue,
185 };
186 if !quadlet_belongs_to(&name, svc_name, all_service_names) {
187 continue;
188 }
189 let content = match std::fs::read_to_string(&path) {
190 Ok(c) => c,
191 Err(_) => continue,
192 };
193 if content.contains(&marker) {
194 continue;
195 }
196 let real_path = match std::fs::canonicalize(&path) {
200 Ok(p) => p,
201 Err(_) => continue,
202 };
203 let updated = generate::bundle::inject_networks(
204 &content,
205 std::slice::from_ref(&network_name.to_string()),
206 );
207 steps.push(Step::WriteFile(GeneratedFile {
208 path: real_path,
209 content: updated,
210 }));
211 let unit = name.trim_end_matches(".container").to_string();
214 units_to_restart.push(unit);
215 }
216 if !units_to_restart.is_empty() {
217 steps.push(Step::DaemonReload);
218 for unit in units_to_restart {
219 steps.push(Step::RestartService { unit });
220 }
221 }
222 steps
223}
224
225fn store_container_port(store_name: &str) -> Option<u16> {
228 let def = capability::lookup_registry_def(store_name)?;
229 def.ports
230 .iter()
231 .find(|p| p.name.eq_ignore_ascii_case("http"))
232 .or_else(|| def.ports.first())
233 .map(|p| p.container_port)
234}
235
236fn retroactive_metrics_wiring(
242 store_name: &str,
243 store_def: ®istry::service_def::ServiceDef,
244 quadlet_path: &std::path::Path,
245) -> Vec<Step> {
246 let mut steps = Vec::new();
247 let installed = list_installed().unwrap_or_default();
248 let store_port = store_def
249 .ports
250 .iter()
251 .find(|p| p.name.eq_ignore_ascii_case("http"))
252 .or_else(|| store_def.ports.first())
253 .map(|p| p.container_port);
254 let installed_names_owned: Vec<String> = installed.iter().map(|s| s.name.clone()).collect();
255 let all_service_names: Vec<&str> = installed_names_owned.iter().map(|s| s.as_str()).collect();
256
257 for svc in &installed {
258 if svc.name == store_name {
259 continue;
260 }
261 let Some(def) = capability::lookup_registry_def(&svc.name) else {
265 continue;
266 };
267 let mut dashboard_wired = false;
268 let mut needs_join = false;
269 let metrics_host_port = def.metrics.as_ref().and_then(|m| {
274 upgrade::read_existing_ports(&svc.name)
275 .ok()
276 .and_then(|ports| ports.get(&m.port.to_ascii_lowercase()).copied())
277 });
278 if let Ok(Some(step)) =
279 metrics_bridge::scrape_target_step(store_name, &def, metrics_host_port)
280 {
281 steps.push(step);
282 needs_join = def.metrics.as_ref().is_some_and(|m| !m.host_network);
283 }
284 if def
285 .capabilities
286 .provides
287 .contains(&Capability::MetricsDashboard)
288 && let Some(port) = store_port
289 && let Ok(step) = metrics_bridge::datasource_step(&svc.name, store_name, port)
290 {
291 steps.push(step);
292 dashboard_wired = true;
293 needs_join = true;
294 }
295 if !needs_join {
296 continue;
297 }
298 let join_steps =
299 network_join_steps(&svc.name, store_name, quadlet_path, &all_service_names);
300 let restarts_main = join_steps
304 .iter()
305 .any(|s| matches!(s, Step::RestartService { unit } if unit == &svc.name));
306 steps.extend(join_steps);
307 if dashboard_wired && !restarts_main {
308 steps.push(Step::RestartService {
309 unit: svc.name.clone(),
310 });
311 }
312 }
313 steps
314}
315
316fn service_uses_smtp_relay(service_name: &str, relay_host: &str) -> bool {
322 let env_path = match service_home(service_name) {
323 Ok(h) => h.join(".env"),
324 Err(_) => return false,
325 };
326 let content = match std::fs::read_to_string(&env_path) {
327 Ok(c) => c,
328 Err(_) => return false,
329 };
330 let with_port = format!("{relay_host}:");
331 content.lines().any(|line| {
332 let Some((_, value)) = line.split_once('=') else {
333 return false;
334 };
335 let v = value.trim();
336 v == relay_host || v.starts_with(&with_port)
337 })
338}
339
340#[allow(clippy::too_many_arguments)]
355fn resolve_extra_networks(
356 service_name: &str,
357 enable_auth: bool,
358 authelia_installed: bool,
359 caddy_installed: bool,
360 inbucket_installed: bool,
361 has_url: bool,
362 has_smtp: bool,
363 metrics_store: Option<&str>,
364 wants_metrics: bool,
365) -> Vec<String> {
366 let mut networks = Vec::new();
367 if enable_auth && authelia_installed && !WellKnownService::Authelia.matches(service_name) {
368 networks.push(WellKnownService::Authelia.to_string());
369 }
370 let joins_inbucket =
373 has_smtp && inbucket_installed && !WellKnownService::Inbucket.matches(service_name);
374 if joins_inbucket {
375 networks.push(WellKnownService::Inbucket.to_string());
376 }
377 let joins_caddy = (has_url || enable_auth || WellKnownService::Inbucket.matches(service_name))
378 && caddy_installed
379 && !WellKnownService::Caddy.matches(service_name);
380 if joins_caddy && !networks.contains(&WellKnownService::Caddy.to_string()) {
381 networks.push(WellKnownService::Caddy.to_string());
382 }
383 if let Some(store) = metrics_store
386 && wants_metrics
387 && store != service_name
388 {
389 networks.push(store.to_string());
390 }
391 networks
392}
393
394#[derive(Debug, Clone, Copy, PartialEq, Eq)]
401pub enum PlanMode {
402 Add,
406 Upgrade,
410}
411
412#[derive(Debug, Clone, PartialEq, Eq)]
421pub enum AuthChoice {
422 None,
424 Native(registry::service_def::AuthKind),
427}
428
429impl AuthChoice {
430 pub fn enabled(&self) -> bool {
433 !matches!(self, AuthChoice::None)
434 }
435
436 pub fn native_kind(&self) -> Option<®istry::service_def::AuthKind> {
439 match self {
440 AuthChoice::Native(kind) => Some(kind),
441 AuthChoice::None => None,
442 }
443 }
444}
445
446pub struct AddServiceParams<'a> {
451 pub service_name: &'a str,
452 pub exposure: &'a Exposure,
453 pub auth: AuthChoice,
454 pub enable_smtp: bool,
455 pub enable_backup: bool,
456 pub env_overrides: &'a BTreeMap<String, String>,
457 pub enabled_groups: &'a std::collections::BTreeSet<String>,
458 pub registry_name: &'a str,
459 pub repo_dir: &'a Path,
460 pub pre_built_ctx: Option<BTreeMap<String, String>>,
464 pub port_in_use: &'a dyn Fn(u16) -> bool,
465 pub acme_mode: Option<&'a caddy::AcmeMode>,
466 pub mode: PlanMode,
467 pub port_overrides: &'a BTreeMap<String, u16>,
473}
474
475pub fn add_service(params: AddServiceParams<'_>) -> Result<AddResult> {
477 let AddServiceParams {
478 service_name,
479 exposure,
480 auth,
481 enable_smtp,
482 enable_backup,
483 env_overrides,
484 enabled_groups,
485 registry_name,
486 repo_dir,
487 pre_built_ctx,
488 port_in_use,
489 acme_mode,
490 mode,
491 port_overrides,
492 } = params;
493 let auth_kind: Option<®istry::service_def::AuthKind> = auth.native_kind();
498 let enable_auth: bool = auth.enabled();
499 let url: Option<&str> = exposure.url();
500 let paths = ConfigPaths::resolve()?;
501 let config = config::load_or_default(&paths.config_file)?;
502
503 if mode == PlanMode::Add {
509 if is_service_installed(service_name) {
510 return Err(Error::ServiceAlreadyInstalled(service_name.to_string()));
511 }
512
513 if data::enumerate_service(service_name)?.is_some() {
521 return Err(Error::ServiceIncomplete(service_name.to_string()));
522 }
523 }
524
525 let reg_service = registry::find_service(repo_dir, service_name)?;
526
527 if let Some(msg) = reg_service.def.check_architecture() {
529 return Err(Error::UnsupportedArchitecture(msg));
530 }
531
532 let missing_requires: Vec<&str> = reg_service
534 .def
535 .requires
536 .iter()
537 .filter(|r| !is_service_installed(&r.service))
538 .map(|r| r.service.as_str())
539 .collect();
540 if !missing_requires.is_empty() {
541 return Err(Error::MissingRequiredServices {
542 service: service_name.to_string(),
543 missing: missing_requires.iter().map(|s| s.to_string()).collect(),
544 });
545 }
546
547 if auth_kind.is_some() && config.auth.is_none() {
549 return Err(Error::AuthNotConfigured);
550 }
551
552 if enable_auth
556 && reg_service.def.integrations.auth.is_empty()
557 && !capability::def_provides(®_service.def, Capability::OidcProvider)
558 {
559 return Err(Error::NoOidcSupport(service_name.to_string()));
560 }
561
562 if enable_backup && !reg_service.def.integrations.backup {
566 return Err(Error::BackupNotSupported(service_name.to_string()));
567 }
568
569 for g in enabled_groups {
573 if !reg_service.def.env_groups.iter().any(|eg| &eg.name == g) {
574 let known: Vec<String> = reg_service
575 .def
576 .env_groups
577 .iter()
578 .map(|eg| eg.name.clone())
579 .collect();
580 let hint = if known.is_empty() {
581 " (service defines no env_groups)".to_string()
582 } else {
583 format!(" (known: {})", known.join(", "))
584 };
585 return Err(Error::UnknownEnvGroup {
586 service: service_name.to_string(),
587 group: g.clone(),
588 hint,
589 });
590 }
591 }
592
593 let mut port_warnings: Vec<Warning> = Vec::new();
599 let mut claimed: std::collections::HashSet<u16> = reg_service
600 .def
601 .ports
602 .iter()
603 .filter_map(|p| p.host_port)
604 .collect();
605 let mut resolved_ports: Vec<(String, u16)> = Vec::with_capacity(reg_service.def.ports.len());
606 for p in ®_service.def.ports {
607 let host = if let Some(pinned) = port_overrides.get(&p.name) {
608 *pinned
613 } else if let Some(hp) = p.host_port {
614 hp
615 } else {
616 let privileged = p.container_port < 1024;
617 let claimed_in_service = claimed.contains(&p.container_port);
618 let in_use = port_in_use(p.container_port);
619 if privileged || claimed_in_service || in_use {
620 let allocated = system::port::allocate_port_excluding(&claimed, port_in_use)?;
621 let reason = if privileged {
622 "port is privileged (requires root)".to_string()
623 } else if claimed_in_service {
624 format!(
625 "port {} is already claimed by another port in this service",
626 p.container_port
627 )
628 } else {
629 format!("port {} is already in use", p.container_port)
630 };
631 port_warnings.push(Warning::PortReassigned {
632 service_name: service_name.to_string(),
633 port_name: p.name.clone(),
634 original_port: p.container_port,
635 assigned_port: allocated,
636 reason,
637 });
638 allocated
639 } else {
640 p.container_port
641 }
642 };
643 claimed.insert(host);
644 resolved_ports.push((p.name.clone(), host));
645 }
646
647 if WellKnownService::Caddy.matches(service_name)
654 && system::sysctl::rootless_can_bind_low_ports()
655 {
656 for (name, port) in resolved_ports.iter_mut() {
657 match name.as_str() {
658 "http" if *port == 8080 => *port = 80,
659 "https" if *port == 8443 => *port = 443,
660 _ => {}
661 }
662 }
663 }
664
665 let host_port = resolved_ports
668 .iter()
669 .find(|(name, _)| name.eq_ignore_ascii_case("http"))
670 .or_else(|| resolved_ports.first())
671 .map(|(_, p)| *p);
672
673 for (_, port) in &resolved_ports {
677 if port_in_use(*port) {
678 return Err(Error::PortConflict { port: *port });
679 }
680 }
681
682 let home_dir = service_home(service_name)?;
683 let quadlet_path = quadlet_dir()?;
684
685 let installed_now = list_installed().unwrap_or_default();
689 let authelia_installed =
690 find_installed_provider(&installed_now, Capability::OidcProvider).is_some();
691 let caddy_installed =
692 find_installed_provider(&installed_now, Capability::ReverseProxy).is_some();
693 let inbucket_installed =
694 find_installed_provider(&installed_now, Capability::SmtpRelay).is_some();
695 let metrics_store =
696 find_installed_provider(&installed_now, Capability::MetricsStore).map(|s| s.name.clone());
697
698 let auth_bridge = auth_bridge::build(&auth_bridge::AuthBridgeParams {
702 service_name,
703 service_provides: ®_service.def.capabilities.provides,
704 enable_auth,
705 config: &config,
706 installed: &installed_now,
707 service_data: &home_dir,
708 })?;
709
710 let (extra_volumes, extra_env, extra_exec_start_pre, auth_bridge_steps) = match auth_bridge {
711 Some(b) => (b.volumes, b.env, b.exec_start_pre, b.steps),
712 None => (Vec::new(), BTreeMap::new(), Vec::new(), Vec::new()),
713 };
714
715 let has_smtp = enable_smtp
716 && reg_service.def.integrations.smtp
717 && !reg_service.def.mappings.smtp.is_empty()
718 && config.smtp.is_some();
719 let wants_metrics = reg_service
722 .def
723 .metrics
724 .as_ref()
725 .is_some_and(|m| !m.host_network)
726 || capability::def_provides(®_service.def, Capability::MetricsDashboard);
727 let extra_networks = resolve_extra_networks(
728 service_name,
729 enable_auth,
730 authelia_installed,
731 caddy_installed,
732 inbucket_installed,
733 url.is_some(),
734 has_smtp,
735 metrics_store.as_deref(),
736 wants_metrics,
737 );
738
739 let output = generate::generate_env(generate::GenerateEnvParams {
740 config: &config,
741 service_def: ®_service.def,
742 auth_kind,
743 host_port,
744 resolved_ports: &resolved_ports,
745 env_overrides,
746 exposure,
747 extra_env,
748 pre_built_ctx,
749 enable_smtp: has_smtp,
750 enabled_groups,
751 })?;
752
753 let podman_args: Vec<String> = Vec::new();
754
755 let port_names: Vec<String> = resolved_ports.iter().map(|(n, _)| n.clone()).collect();
757
758 let install_metadata = Metadata {
771 registry: registry_name.to_string(),
772 url: url.map(str::to_string),
773 auth: auth_kind.cloned(),
774 provides: reg_service.def.capabilities.provides.clone(),
775 backup_enabled: enable_backup,
776 smtp_enabled: enable_smtp,
777 enabled_groups: enabled_groups.iter().cloned().collect(),
778 runtime: reg_service.def.service.runtime.clone(),
779 };
780
781 if reg_service.def.service.runtime == registry::service_def::Runtime::Native {
786 let tracked_envs = collect_static_envs(®_service.def, &output.ctx, enabled_groups)?;
787 let allocated_ports = resolved_ports.clone();
788 let generated_secrets = collect_generated_secrets(®_service.def, env_overrides);
789 return build_native_add(NativeAddParams {
790 service_name,
791 reg_service: ®_service,
792 home_dir: &home_dir,
793 output,
794 install_metadata: &install_metadata,
795 registry_name,
796 url,
797 tracked_envs,
798 allocated_ports,
799 generated_secrets,
800 });
801 }
802
803 let bundle =
805 generate::bundle::process_quadlet_bundle(&generate::bundle::ProcessBundleParams {
806 service_dir: ®_service.service_dir,
807 service_name,
808 extra_networks: &extra_networks,
809 extra_volumes: &extra_volumes,
810 podman_args: &podman_args,
811 extra_exec_start_pre: &extra_exec_start_pre,
812 port_names: &port_names,
813 })?;
814
815 let mut warnings = Vec::new();
817
818 if let Some(ref reqs) = reg_service.def.requirements
819 && let Some(total) = system::memory::total_ram_mb()
820 {
821 if total < reqs.ram.min {
822 warnings.push(Warning::RamBelowMinimum {
823 service_name: service_name.to_string(),
824 min_mb: reqs.ram.min,
825 available_mb: total,
826 });
827 } else if let Some(rec) = reqs.ram.recommended
828 && total < rec
829 {
830 warnings.push(Warning::RamBelowRecommended {
831 service_name: service_name.to_string(),
832 recommended_mb: rec,
833 available_mb: total,
834 });
835 }
836 }
837 warnings.extend(port_warnings);
838
839 let mut steps = Vec::new();
841
842 steps.push(Step::CreateDir(home_dir.clone()));
844
845 let env_content = output.env_file.content.clone();
847
848 for image in &bundle.images {
850 steps.push(Step::PullImage {
851 image: image.clone(),
852 });
853 }
854
855 for file in bundle.quadlet_files {
859 let link = file
860 .path
861 .file_name()
862 .map(|n| quadlet_path.join(n))
863 .ok_or_else(|| {
864 Error::Bundle(format!("invalid quadlet path: {}", file.path.display()))
865 })?;
866 let target = file.path.clone();
867 steps.push(Step::WriteFile(file));
868 steps.push(Step::Symlink { link, target });
869 }
870
871 let metadata_content = toml::to_string_pretty(&install_metadata)?;
878 steps.push(Step::WriteFile(GeneratedFile {
879 path: metadata_path(service_name)?,
880 content: metadata_content,
881 }));
882
883 if mode == PlanMode::Add && exposure.is_tailscale() {
891 let svc_name = exposure.tailscale_svc_name().ok_or_else(|| {
901 Error::InvalidServiceRef(format!(
902 "tailscale exposure for '{service_name}' has a malformed URL — \
903 expected `https://<service>-<host>.<tailnet>.ts.net/`"
904 ))
905 })?;
906 let ts_ports = plan::tailscale_ports(®_service.def.ports, &resolved_ports, host_port);
910 if !ts_ports.is_empty() {
911 steps.push(Step::TailscaleSetup);
912 steps.push(Step::TailscaleEnable {
913 svc_name,
914 ports: ts_ports,
915 });
916 }
917 }
918
919 for file in bundle.config_files {
921 steps.push(Step::WriteFile(file));
922 }
923
924 for (src, dst) in bundle.files {
928 steps.push(Step::CopyFile { src, dst });
929 }
930
931 steps.push(Step::WriteFile(output.env_file));
933
934 for dir in &bundle.bind_mount_dirs {
936 steps.push(Step::CreateDir(dir.clone()));
937 }
938
939 steps.extend(auth_bridge_steps);
943
944 if mode == PlanMode::Add
953 && let (
954 Some(registry::service_def::AuthKind::Oidc),
955 Some(config::schema::AuthCredentials::Authelia { .. }),
956 ) = (auth_kind, config.auth.as_ref())
957 {
958 steps.extend(authelia::register_oidc_client(
959 service_name,
960 ®_service.def,
961 url,
962 &output.ctx,
963 &config,
964 &quadlet_path,
965 )?);
966 }
967
968 if let Some(url) = url
974 && !WellKnownService::Caddy.matches(service_name)
975 && !exposure.is_tailscale()
976 {
977 if caddy_installed {
978 let parsed = url::Url::parse(url)
979 .map_err(|e| Error::Template(format!("invalid service URL '{url}': {e}")))?;
980 let domain = parsed.host_str().ok_or_else(|| {
981 Error::Template(format!(
982 "service URL '{url}' has no host — Caddy needs a hostname to route to"
983 ))
984 })?;
985 let container_port = reg_service
986 .def
987 .ports
988 .first()
989 .map(|p| p.container_port)
990 .unwrap_or(80);
991 let primary_quadlet = reg_service
992 .service_dir
993 .join("quadlets")
994 .join(format!("{service_name}.container"));
995 let target_host = caddy::primary_container_name(&primary_quadlet, service_name);
996 let block = caddy::render_site_block(&caddy::CaddySiteParams {
997 service_name: service_name.to_string(),
998 target_host,
999 domain: domain.to_string(),
1000 container_port,
1001 https_port: caddy_https_port(&config),
1002 });
1003 let caddyfile_path = caddy::caddyfile_path()?;
1004 let existing =
1005 std::fs::read_to_string(&caddyfile_path).map_err(|source| Error::FileRead {
1006 path: caddyfile_path.clone(),
1007 source,
1008 })?;
1009 let updated = caddy::add_route(&existing, service_name, &block);
1010 steps.push(Step::WriteFile(GeneratedFile {
1011 path: caddyfile_path,
1012 content: updated,
1013 }));
1014 steps.push(Step::ReloadCaddy);
1015 } else if let Some(primary) = host_port {
1016 warnings.push(Warning::UrlWithoutReverseProxy {
1021 service_name: service_name.to_string(),
1022 url: url.to_string(),
1023 host_port: primary,
1024 });
1025 }
1026 }
1027
1028 if mode == PlanMode::Add {
1038 steps.extend(retroactive_network_joins(
1039 service_name,
1040 &quadlet_path,
1041 Some(repo_dir),
1042 ));
1043 }
1044
1045 if mode == PlanMode::Add {
1052 if let Some(store) = &metrics_store {
1053 let metrics_host_port = reg_service.def.metrics.as_ref().and_then(|m| {
1054 resolved_ports
1055 .iter()
1056 .find(|(n, _)| n == &m.port)
1057 .map(|(_, p)| *p)
1058 });
1059 if let Some(step) =
1060 metrics_bridge::scrape_target_step(store, ®_service.def, metrics_host_port)?
1061 {
1062 steps.push(step);
1063 }
1064 if capability::def_provides(®_service.def, Capability::MetricsDashboard)
1065 && let Some(port) = store_container_port(store)
1066 {
1067 steps.push(metrics_bridge::datasource_step(service_name, store, port)?);
1068 }
1069 }
1070 if capability::def_provides(®_service.def, Capability::MetricsStore) {
1071 steps.extend(retroactive_metrics_wiring(
1072 service_name,
1073 ®_service.def,
1074 &quadlet_path,
1075 ));
1076 }
1077 }
1078
1079 if WellKnownService::Caddy.matches(service_name) {
1086 let snippet_path = caddy::tls_snippet_path()?;
1087 if !snippet_path.exists() {
1088 let mode = acme_mode.cloned().unwrap_or(caddy::AcmeMode::Internal);
1089 steps.push(Step::WriteFile(GeneratedFile {
1090 path: snippet_path,
1091 content: mode.snippet(),
1092 }));
1093 }
1094 }
1095
1096 let manifest_path_for_svc = manifest::manifest_path(service_name)?;
1105 let env_filename = std::ffi::OsStr::new(".env");
1106 let mut manifest_entries: Vec<manifest::ManifestEntry> = Vec::new();
1107 for step in &steps {
1108 if let Step::WriteFile(file) = step {
1109 if file.path == manifest_path_for_svc {
1110 continue;
1111 }
1112 if file.path.file_name() == Some(env_filename) {
1113 continue;
1114 }
1115 manifest_entries.push(manifest::ManifestEntry {
1116 path: file.path.clone(),
1117 sha256: manifest::hash_bytes(file.content.as_bytes()),
1118 });
1119 }
1120 }
1121 let tracked_envs = collect_static_envs(®_service.def, &output.ctx, enabled_groups)?;
1129 let manifest_envs: Vec<manifest::EnvEntry> = tracked_envs
1130 .iter()
1131 .map(|t| manifest::EnvEntry {
1132 key: t.key.clone(),
1133 value: t.value.clone(),
1134 })
1135 .collect();
1136 steps.push(Step::WriteFile(GeneratedFile {
1137 path: manifest_path_for_svc,
1138 content: manifest::format(&manifest_entries, &manifest_envs),
1139 }));
1140
1141 steps.push(Step::DaemonReload);
1143 steps.push(Step::StartService {
1145 unit: service_name.to_string(),
1146 });
1147
1148 let allocated_ports: Vec<(String, u16)> = resolved_ports.clone();
1150
1151 let mut generated_secrets: Vec<String> = reg_service
1153 .def
1154 .env
1155 .iter()
1156 .filter(|e| !env_overrides.contains_key(&e.name))
1157 .flat_map(|e| generate::extract_secret_refs(&e.value))
1158 .collect();
1159 generated_secrets.sort();
1161 generated_secrets.dedup();
1162
1163 Ok(AddResult {
1164 steps,
1165 warnings,
1166 repo_url: registry_name.to_string(),
1167 allocated_ports,
1168 generated_secrets,
1169 env_content,
1170 url: url.map(|u| u.to_string()),
1171 tracked_envs,
1172 })
1173}
1174
1175fn collect_generated_secrets(
1178 def: ®istry::service_def::ServiceDef,
1179 env_overrides: &BTreeMap<String, String>,
1180) -> Vec<String> {
1181 let mut out: Vec<String> = def
1182 .env
1183 .iter()
1184 .filter(|e| !env_overrides.contains_key(&e.name))
1185 .flat_map(|e| generate::extract_secret_refs(&e.value))
1186 .collect();
1187 out.sort();
1188 out.dedup();
1189 out
1190}
1191
1192struct NativeAddParams<'a> {
1194 service_name: &'a str,
1195 reg_service: &'a registry::RegistryService,
1196 home_dir: &'a Path,
1197 output: generate::EnvOutput,
1198 install_metadata: &'a Metadata,
1199 registry_name: &'a str,
1200 url: Option<&'a str>,
1201 tracked_envs: Vec<TrackedEnv>,
1202 allocated_ports: Vec<(String, u16)>,
1203 generated_secrets: Vec<String>,
1204}
1205
1206fn build_native_add(p: NativeAddParams<'_>) -> Result<AddResult> {
1212 let NativeAddParams {
1213 service_name,
1214 reg_service,
1215 home_dir,
1216 output,
1217 install_metadata,
1218 registry_name,
1219 url,
1220 tracked_envs,
1221 allocated_ports,
1222 generated_secrets,
1223 } = p;
1224
1225 let run = reg_service.def.service.run.as_ref().ok_or_else(|| {
1226 Error::Bundle(format!(
1227 "native service '{service_name}' is missing its `run` command"
1228 ))
1229 })?;
1230 let build = reg_service.def.service.build.as_ref();
1231
1232 let env_content = output.env_file.content.clone();
1233 let source_dir = reg_service.service_dir.clone();
1234 let mut steps = Vec::new();
1235
1236 steps.push(Step::CreateDir(home_dir.to_path_buf()));
1241 steps.push(Step::CreateDir(home_dir.join("data")));
1242
1243 if let Some(command) = build {
1245 steps.push(Step::Build {
1246 dir: source_dir.clone(),
1247 command: command.clone(),
1248 });
1249 }
1250
1251 steps.push(Step::WriteFile(GeneratedFile {
1253 path: metadata_path(service_name)?,
1254 content: toml::to_string_pretty(install_metadata)?,
1255 }));
1256 steps.push(Step::WriteFile(output.env_file));
1257
1258 let unit_name = format!("{service_name}.service");
1261 let unit_path = home_dir.join(&unit_name);
1262 steps.push(Step::WriteFile(GeneratedFile {
1263 path: unit_path.clone(),
1264 content: native_unit(
1265 home_dir,
1266 &source_dir,
1267 run,
1268 ®_service.def.service.description,
1269 ),
1270 }));
1271 steps.push(Step::Symlink {
1272 link: systemd_user_dir()?.join(&unit_name),
1273 target: unit_path,
1274 });
1275
1276 steps.push(Step::DaemonReload);
1277 steps.push(Step::StartService {
1278 unit: service_name.to_string(),
1279 });
1280
1281 Ok(AddResult {
1282 steps,
1283 warnings: Vec::new(),
1284 repo_url: registry_name.to_string(),
1285 allocated_ports,
1286 generated_secrets,
1287 env_content,
1288 url: url.map(|u| u.to_string()),
1289 tracked_envs,
1290 })
1291}
1292
1293fn native_unit(home_dir: &Path, source_dir: &Path, run: &str, description: &str) -> String {
1298 let home = home_dir.display();
1299 let source = source_dir.display();
1300 format!(
1309 "[Unit]\n\
1310 Description={description}\n\
1311 After=network.target\n\
1312 \n\
1313 [Service]\n\
1314 Type=simple\n\
1315 WorkingDirectory={source}\n\
1316 EnvironmentFile={home}/.env\n\
1317 Environment=SERVICE_HOME={home}\n\
1318 Environment=PATH=%h/.local/bin:%h/.cargo/bin:%h/.bun/bin:%h/.deno/bin:%h/go/bin:/usr/local/bin:/usr/bin:/bin\n\
1319 ExecStart=/bin/sh -c 'exec {run}'\n\
1320 Restart=always\n\
1321 RestartSec=5\n\
1322 \n\
1323 [Install]\n\
1324 WantedBy=default.target\n",
1325 )
1326}
1327
1328pub fn quadlet_belongs_to(filename: &str, service_name: &str, all_service_names: &[&str]) -> bool {
1337 if !filename.starts_with(service_name) {
1338 return false;
1339 }
1340 let rest = &filename[service_name.len()..];
1341 if rest.starts_with('.') {
1342 return true;
1343 }
1344 if !rest.starts_with('-') {
1345 return false;
1346 }
1347 !all_service_names.iter().any(|&other| {
1351 other.len() > service_name.len()
1352 && other.starts_with(service_name)
1353 && filename.starts_with(other)
1354 && filename[other.len()..].starts_with(['.', '-'])
1355 })
1356}
1357
1358#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
1360#[serde(rename_all = "snake_case")]
1361pub enum RemoveMode {
1362 #[default]
1363 Preserve,
1368 Purge,
1370}
1371
1372pub fn remove_service(service_name: &str, mode: RemoveMode) -> Result<RemoveResult> {
1374 let installed_owned = build_installed_from_metadata(service_name)
1377 .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
1378 let installed = &installed_owned;
1379
1380 if let Ok(Some(meta)) = metadata::load_metadata(service_name)
1385 && meta.runtime == registry::service_def::Runtime::Native
1386 {
1387 let url = installed.exposure.url().map(|s| s.to_string());
1388 return remove_native_service(service_name, mode, url);
1389 }
1390
1391 let quadlet_path = quadlet_dir()?;
1394 let mut steps = Vec::new();
1395 let mut volume_names = Vec::new();
1396 let mut networks: Vec<String> = Vec::new();
1397 let mut has_named_volumes = false;
1398 let name_pool = scan_managed_services().unwrap_or_default();
1402 let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
1403
1404 if let Some(svc_name) = installed.exposure.tailscale_svc_name() {
1416 steps.push(Step::TailscaleDisable { svc_name });
1417 }
1418
1419 if quadlet_path.is_dir()
1420 && let Ok(entries) = std::fs::read_dir(&quadlet_path)
1421 {
1422 for entry in entries.flatten() {
1423 let file_name = entry.file_name();
1424 let name = file_name.to_string_lossy();
1425 if !quadlet_belongs_to(&name, service_name, &all_names) {
1428 continue;
1429 }
1430 if name.ends_with(".container") {
1432 let unit = name.trim_end_matches(".container").to_string();
1433 steps.push(Step::StopService { unit });
1434 }
1435 if name.ends_with(".network") {
1436 let net = name.trim_end_matches(".network").to_string();
1439 steps.push(Step::StopService {
1440 unit: format!("{net}-network"),
1441 });
1442 networks.push(net);
1443 }
1444 if name.ends_with(".volume") {
1445 has_named_volumes = true;
1446 if matches!(mode, RemoveMode::Purge) {
1447 let vol = name.trim_end_matches(".volume").to_string();
1448 volume_names.push(format!("systemd-{vol}"));
1450 }
1451 }
1452 steps.push(Step::RemoveFile(entry.path()));
1453 }
1454 }
1455
1456 let had_caddy_route = matches!(
1463 installed.exposure,
1464 Exposure::Internal { .. } | Exposure::Public { .. }
1465 );
1466 if !WellKnownService::Caddy.matches(service_name) && had_caddy_route {
1467 let caddyfile_path = caddy::caddyfile_path()?;
1468 if caddyfile_path.exists() {
1469 let existing =
1470 std::fs::read_to_string(&caddyfile_path).map_err(|source| Error::FileRead {
1471 path: caddyfile_path.clone(),
1472 source,
1473 })?;
1474 let updated = caddy::remove_route(&existing, service_name);
1475 if updated != existing {
1476 steps.push(Step::WriteFile(GeneratedFile {
1477 path: caddyfile_path,
1478 content: updated.clone(),
1479 }));
1480 if !updated.trim().is_empty() {
1483 steps.push(Step::ReloadCaddy);
1484 }
1485 }
1486 }
1487 }
1488
1489 if !WellKnownService::Authelia.matches(service_name)
1490 && matches!(
1491 installed.auth_kind,
1492 Some(registry::service_def::AuthKind::Oidc)
1493 )
1494 {
1495 steps.extend(authelia::unregister_oidc_client(service_name)?);
1496 }
1497
1498 let installed_all = list_installed().unwrap_or_default();
1504 for store in installed_all
1505 .iter()
1506 .filter(|s| installed_provides(s, Capability::MetricsStore))
1507 {
1508 if store.name != service_name
1509 && let Ok(target) = metrics_bridge::target_file_path(&store.name, service_name)
1510 && target.exists()
1511 {
1512 steps.push(Step::RemoveFile(target));
1513 }
1514 }
1515 if installed.provides.contains(&Capability::MetricsStore) {
1516 for dash in installed_all
1517 .iter()
1518 .filter(|s| installed_provides(s, Capability::MetricsDashboard))
1519 {
1520 if dash.name == service_name {
1521 continue;
1522 }
1523 if let Ok(ds) = metrics_bridge::datasource_file_path(&dash.name, service_name)
1524 && ds.exists()
1525 {
1526 steps.push(Step::RemoveFile(ds));
1527 steps.push(Step::RestartService {
1528 unit: dash.name.clone(),
1529 });
1530 }
1531 }
1532 }
1533
1534 steps.push(Step::DaemonReload);
1536
1537 for net in networks {
1545 steps.push(Step::RemoveNetwork { name: net });
1546 }
1547
1548 match mode {
1549 RemoveMode::Purge => {
1550 for vol_name in volume_names {
1552 steps.push(Step::RemoveVolume { name: vol_name });
1553 }
1554 steps.push(Step::RemoveDir(service_home(service_name)?));
1556 }
1557 RemoveMode::Preserve => {
1558 let home = service_home(service_name)?;
1562 let (data, ephemeral) = crate::data::classify::classify_home_dir(&home)?;
1563 for path in ephemeral {
1564 match std::fs::metadata(&path) {
1565 Ok(m) if m.is_dir() => steps.push(Step::RemoveDir(path)),
1566 Ok(_) => steps.push(Step::RemoveFile(path)),
1567 Err(_) => steps.push(Step::RemoveFile(path)),
1571 }
1572 }
1573 if data.is_empty() && !has_named_volumes && home.exists() {
1581 steps.push(Step::RemoveDir(home));
1582 }
1583 }
1584 }
1585
1586 let url = installed.exposure.url().map(|s| s.to_string());
1587
1588 Ok(RemoveResult {
1589 steps,
1590 service_name: service_name.to_string(),
1591 url,
1592 })
1593}
1594
1595fn remove_native_service(
1600 service_name: &str,
1601 mode: RemoveMode,
1602 url: Option<String>,
1603) -> Result<RemoveResult> {
1604 let home = service_home(service_name)?;
1605 let unit_name = format!("{service_name}.service");
1606 let mut steps = vec![
1607 Step::StopService {
1608 unit: service_name.to_string(),
1609 },
1610 Step::RemoveFile(systemd_user_dir()?.join(&unit_name)),
1611 Step::DaemonReload,
1612 ];
1613
1614 match mode {
1615 RemoveMode::Purge => steps.push(Step::RemoveDir(home)),
1616 RemoveMode::Preserve => {
1617 for child in ["bin", ".env", unit_name.as_str()] {
1620 let p = home.join(child);
1621 match std::fs::metadata(&p) {
1622 Ok(m) if m.is_dir() => steps.push(Step::RemoveDir(p)),
1623 _ => steps.push(Step::RemoveFile(p)),
1624 }
1625 }
1626 }
1627 }
1628
1629 Ok(RemoveResult {
1630 steps,
1631 service_name: service_name.to_string(),
1632 url,
1633 })
1634}
1635
1636#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1638#[serde(rename_all = "snake_case")]
1639pub enum Lifecycle {
1640 Start,
1641 Stop,
1642}
1643
1644pub fn lifecycle_steps(service_name: &str, action: Lifecycle) -> Result<Vec<Step>> {
1653 build_installed_from_metadata(service_name)
1655 .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
1656
1657 if matches!(
1660 metadata::load_metadata(service_name),
1661 Ok(Some(m)) if m.runtime == registry::service_def::Runtime::Native
1662 ) {
1663 let unit = service_name.to_string();
1664 return Ok(vec![match action {
1665 Lifecycle::Start => Step::StartService { unit },
1666 Lifecycle::Stop => Step::StopService { unit },
1667 }]);
1668 }
1669
1670 let mut units = service_container_units(service_name)?;
1671 match action {
1672 Lifecycle::Stop => units.sort_by_key(|u| u != service_name),
1674 Lifecycle::Start => units.sort_by_key(|u| u == service_name),
1676 }
1677
1678 Ok(units
1679 .into_iter()
1680 .map(|unit| match action {
1681 Lifecycle::Start => Step::StartService { unit },
1682 Lifecycle::Stop => Step::StopService { unit },
1683 })
1684 .collect())
1685}
1686
1687fn service_container_units(service_name: &str) -> Result<Vec<String>> {
1691 let quadlet_path = quadlet_dir()?;
1692 let name_pool = scan_managed_services().unwrap_or_default();
1693 let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
1694
1695 let mut units = Vec::new();
1696 if quadlet_path.is_dir()
1697 && let Ok(entries) = std::fs::read_dir(&quadlet_path)
1698 {
1699 for entry in entries.flatten() {
1700 let file_name = entry.file_name();
1701 let name = file_name.to_string_lossy();
1702 if !quadlet_belongs_to(&name, service_name, &all_names) {
1703 continue;
1704 }
1705 if name.ends_with(".container") {
1706 units.push(name.trim_end_matches(".container").to_string());
1707 }
1708 }
1709 }
1710 Ok(units)
1711}
1712
1713pub struct RecordPendingParams<'a> {
1715 pub service_name: &'a str,
1716 pub auth_kind: Option<registry::service_def::AuthKind>,
1717 pub registry_name: &'a str,
1718 pub allocated_ports: &'a [(String, u16)],
1719 pub repo_dir: &'a Path,
1720 pub exposure: &'a Exposure,
1727}
1728
1729pub fn record_pending(params: RecordPendingParams<'_>) -> Result<()> {
1736 let paths = ConfigPaths::resolve()?;
1737 paths.ensure_dirs()?;
1738 let mut config = config::load_or_default(&paths.config_file)?;
1739
1740 if WellKnownService::Authelia.matches(params.service_name) {
1745 config.auth = Some(authelia::auth_config(
1746 params.allocated_ports,
1747 params.exposure.url(),
1748 )?);
1749 config::save_config(&paths.config_file, &config)?;
1750 }
1751
1752 Ok(())
1753}
1754
1755pub fn finalize_remove(service_name: &str) -> Result<()> {
1762 let paths = ConfigPaths::resolve()?;
1763 let mut config = config::load_or_default(&paths.config_file)?;
1764
1765 if WellKnownService::Authelia.matches(service_name)
1766 && let Some(auth) = &config.auth
1767 && auth.provider_name() == "authelia"
1768 {
1769 config.auth = None;
1770 config::save_config(&paths.config_file, &config)?;
1771 }
1772
1773 Ok(())
1774}
1775
1776const SENSITIVE_TEMPLATE_REFS: &[&str] = &[
1795 "{{secret.",
1796 "{{auth.client_id",
1797 "{{auth.client_secret",
1798 "{{smtp.username",
1799 "{{smtp.password",
1800];
1801
1802fn is_static_template(value: &str) -> bool {
1803 !SENSITIVE_TEMPLATE_REFS.iter().any(|s| value.contains(s))
1804}
1805
1806fn collect_static_envs(
1822 service_def: ®istry::service_def::ServiceDef,
1823 ctx: &BTreeMap<String, String>,
1824 enabled_groups: &std::collections::BTreeSet<String>,
1825) -> Result<Vec<plan::TrackedEnv>> {
1826 use registry::service_def::EnvKind;
1827 let mut out: Vec<plan::TrackedEnv> = Vec::new();
1828 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
1829 let push = |name: &str,
1830 value_template: &str,
1831 kind: EnvKind,
1832 prompt: Option<String>,
1833 out: &mut Vec<plan::TrackedEnv>,
1834 seen: &mut std::collections::HashSet<String>|
1835 -> Result<()> {
1836 if !is_static_template(value_template) {
1837 return Ok(());
1838 }
1839 if !seen.insert(name.to_string()) {
1840 return Ok(());
1841 }
1842 let value = generate::template::render(value_template, ctx)?;
1843 out.push(plan::TrackedEnv {
1844 key: name.to_string(),
1845 value,
1846 kind,
1847 prompt,
1848 });
1849 Ok(())
1850 };
1851 for env in &service_def.env {
1852 push(
1853 &env.name,
1854 &env.value,
1855 env.kind.clone(),
1856 env.prompt.clone(),
1857 &mut out,
1858 &mut seen,
1859 )?;
1860 }
1861 for group in &service_def.env_groups {
1862 if !enabled_groups.contains(&group.name) {
1863 continue;
1864 }
1865 for env in &group.env {
1866 push(
1867 &env.name,
1868 &env.value,
1869 env.kind.clone(),
1870 env.prompt.clone(),
1871 &mut out,
1872 &mut seen,
1873 )?;
1874 }
1875 }
1876 if service_def.integrations.smtp && ctx.contains_key("smtp.host") {
1882 for (env_name, value_template) in &service_def.mappings.smtp {
1883 push(
1884 env_name,
1885 value_template,
1886 EnvKind::Default,
1887 None,
1888 &mut out,
1889 &mut seen,
1890 )?;
1891 }
1892 }
1893 if ctx.contains_key("auth.client_id") {
1894 for (env_name, value_template) in &service_def.mappings.auth {
1895 push(
1896 env_name,
1897 value_template,
1898 EnvKind::Default,
1899 None,
1900 &mut out,
1901 &mut seen,
1902 )?;
1903 }
1904 }
1905 Ok(out)
1906}
1907
1908pub fn orphan_purge_steps(svc: &data::ServiceData) -> Vec<Step> {
1909 let mut steps = Vec::new();
1910
1911 let mut had_quadlet = false;
1917 let mut networks: Vec<String> = Vec::new();
1918 if let Ok(qdir) = quadlet_dir()
1919 && qdir.is_dir()
1920 && let Ok(entries) = std::fs::read_dir(&qdir)
1921 {
1922 let name_pool = scan_managed_services().unwrap_or_default();
1923 let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
1924 for entry in entries.flatten() {
1925 let file_name = entry.file_name();
1926 let name = file_name.to_string_lossy();
1927 if !quadlet_belongs_to(&name, &svc.service, &all_names) {
1928 continue;
1929 }
1930 if name.ends_with(".container") {
1934 let unit = name.trim_end_matches(".container").to_string();
1935 steps.push(Step::StopService { unit });
1936 } else if name.ends_with(".network") {
1937 let net = name.trim_end_matches(".network").to_string();
1938 steps.push(Step::StopService {
1939 unit: format!("{net}-network"),
1940 });
1941 networks.push(net);
1942 } else if name.ends_with(".volume") {
1943 let unit = format!("{}-volume", name.trim_end_matches(".volume"));
1944 steps.push(Step::StopService { unit });
1945 }
1946 steps.push(Step::RemoveFile(entry.path()));
1947 had_quadlet = true;
1948 }
1949 }
1950 if had_quadlet {
1951 steps.push(Step::DaemonReload);
1952 }
1953 for net in networks {
1955 steps.push(Step::RemoveNetwork { name: net });
1956 }
1957
1958 for path in &svc.data_paths {
1959 if path.is_dir() {
1960 steps.push(Step::RemoveDir(path.clone()));
1961 } else {
1962 steps.push(Step::RemoveFile(path.clone()));
1963 }
1964 }
1965 if svc.home_dir.exists() {
1966 steps.push(Step::RemoveDir(svc.home_dir.clone()));
1967 }
1968 for v in &svc.volumes {
1969 steps.push(Step::RemoveVolume {
1970 name: v.name.clone(),
1971 });
1972 }
1973 steps
1974}
1975
1976pub fn reset() -> Result<ResetResult> {
1978 let mut steps = Vec::new();
1979
1980 let managed_names = scan_managed_services().unwrap_or_default();
1985
1986 for svc in list_installed().unwrap_or_default() {
1993 if let Some(svc_name) = svc.exposure.tailscale_svc_name() {
1994 steps.push(Step::TailscaleDisable { svc_name });
1995 }
1996 }
1997
1998 let quadlet_path = quadlet_dir()?;
2000 let all_names: Vec<&str> = managed_names.iter().map(|s| s.as_str()).collect();
2001 let mut networks: Vec<String> = Vec::new();
2002 if quadlet_path.is_dir()
2003 && let Ok(entries) = std::fs::read_dir(&quadlet_path)
2004 {
2005 for entry in entries.flatten() {
2006 let file_name = entry.file_name();
2007 let name = file_name.to_string_lossy();
2008 let is_ryra_file = managed_names
2012 .iter()
2013 .any(|svc| quadlet_belongs_to(&name, svc, &all_names));
2014 if !is_ryra_file {
2015 continue;
2016 }
2017 if name.ends_with(".container") {
2018 let unit = name.trim_end_matches(".container").to_string();
2019 steps.push(Step::StopService { unit });
2020 }
2021 if name.ends_with(".network") {
2022 let net = name.trim_end_matches(".network").to_string();
2023 steps.push(Step::StopService {
2024 unit: format!("{net}-network"),
2025 });
2026 networks.push(net);
2027 }
2028 if name.ends_with(".volume") {
2029 let vol = name.trim_end_matches(".volume").to_string();
2030 steps.push(Step::StopService {
2037 unit: format!("{vol}-volume"),
2038 });
2039 }
2040 steps.push(Step::RemoveFile(entry.path()));
2041 }
2042 }
2043
2044 let user_unit_dir = systemd_user_dir()?;
2050 if let Ok(root) = service_data_root()
2051 && let Ok(entries) = std::fs::read_dir(&root)
2052 {
2053 for entry in entries.flatten() {
2054 let Some(name) = entry.file_name().to_str().map(str::to_string) else {
2055 continue;
2056 };
2057 if matches!(
2058 metadata::load_metadata(&name),
2059 Ok(Some(m)) if m.runtime == registry::service_def::Runtime::Native
2060 ) {
2061 steps.push(Step::StopService { unit: name.clone() });
2062 steps.push(Step::RemoveFile(
2063 user_unit_dir.join(format!("{name}.service")),
2064 ));
2065 }
2066 }
2067 }
2068
2069 steps.push(Step::DaemonReload);
2071
2072 for net in networks {
2075 steps.push(Step::RemoveNetwork { name: net });
2076 }
2077
2078 let mut seen_volumes = std::collections::BTreeSet::new();
2084 for svc in data::enumerate_all().unwrap_or_default() {
2085 for vol in svc.volumes {
2086 if seen_volumes.insert(vol.name.clone()) {
2087 steps.push(Step::RemoveVolume { name: vol.name });
2088 }
2089 }
2090 }
2091
2092 let data_root = service_data_root()?;
2098 if data_root.exists() {
2099 steps.push(Step::RemoveDir(data_root));
2100 }
2101
2102 Ok(ResetResult { steps })
2103}
2104
2105pub fn finalize_reset() -> Result<()> {
2107 let paths = ConfigPaths::resolve()?;
2108 if paths.config_dir.exists() {
2109 std::fs::remove_dir_all(&paths.config_dir).map_err(|source| Error::FileWrite {
2110 path: paths.config_dir,
2111 source,
2112 })?;
2113 }
2114 Ok(())
2115}
2116
2117pub fn status() -> config::status::RyraStatus {
2123 let paths = match ConfigPaths::resolve() {
2124 Ok(p) => p,
2125 Err(_) => return config::status::RyraStatus::NotInitialized,
2126 };
2127
2128 let has_quadlets = scan_managed_services()
2129 .map(|n| !n.is_empty())
2130 .unwrap_or(false);
2131
2132 let config = match config::load_config(&paths.config_file) {
2133 Ok(c) => c,
2134 Err(Error::ConfigNotFound(_)) if has_quadlets => config::schema::Config::default(),
2135 Err(Error::ConfigNotFound(_)) => return config::status::RyraStatus::NotInitialized,
2136 Err(e) => return config::status::RyraStatus::Error(e.to_string()),
2137 };
2138
2139 config::status::RyraStatus::Initialized(config::status::StatusInfo::from_config(
2140 paths.config_file,
2141 &config,
2142 ))
2143}
2144
2145pub fn is_service_installed(name: &str) -> bool {
2153 let Ok(Some(meta)) = metadata::load_metadata(name) else {
2157 return false;
2158 };
2159 match meta.runtime {
2160 registry::service_def::Runtime::Native => systemd_user_dir()
2161 .map(|d| d.join(format!("{name}.service")).exists())
2162 .unwrap_or(false),
2163 registry::service_def::Runtime::Podman => scan_managed_services()
2164 .map(|names| names.iter().any(|n| n == name))
2165 .unwrap_or(false),
2166 }
2167}
2168
2169pub fn scan_managed_services() -> Result<Vec<String>> {
2182 let dir = match quadlet_dir() {
2183 Ok(d) => d,
2184 Err(_) => return Ok(Vec::new()),
2185 };
2186 let entries = match std::fs::read_dir(&dir) {
2187 Ok(e) => e,
2188 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
2189 Err(source) => return Err(Error::FileRead { path: dir, source }),
2190 };
2191 let mut names: Vec<String> = Vec::new();
2192 for entry in entries.flatten() {
2193 let path = entry.path();
2194 if path.extension().and_then(|e| e.to_str()) != Some("container") {
2195 continue;
2196 }
2197 let Ok(content) = std::fs::read_to_string(&path) else {
2198 continue;
2199 };
2200 for line in content.lines().take(16) {
2201 if let Some(rest) = line.trim().strip_prefix("# Service-Source: registry/")
2202 && !rest.is_empty()
2203 && !names.iter().any(|n| n == rest)
2204 {
2205 names.push(rest.to_string());
2206 break;
2207 }
2208 }
2209 }
2210 names.sort();
2211 Ok(names)
2212}
2213
2214fn build_installed_from_metadata(service_name: &str) -> Option<InstalledService> {
2220 let meta = load_metadata(service_name).ok().flatten()?;
2221
2222 let exposure = match meta.url.as_deref() {
2224 None => Exposure::Loopback,
2225 Some(u) => Exposure::from_url(u),
2226 };
2227
2228 let auth_kind = meta.auth.clone();
2229
2230 let ports = service_home(service_name)
2236 .ok()
2237 .and_then(|home| std::fs::read_to_string(home.join(".env")).ok())
2238 .map(|env| {
2239 env.lines()
2240 .filter_map(|l| {
2241 let l = l.trim();
2242 if l.is_empty() || l.starts_with('#') {
2243 return None;
2244 }
2245 let (key, val) = l.split_once('=')?;
2246 let name = key.strip_prefix("SERVICE_PORT_")?.to_lowercase();
2247 let port = val
2248 .trim_matches(|c: char| c == '"' || c == '\'')
2249 .parse::<u16>()
2250 .ok()?;
2251 Some((name, port))
2252 })
2253 .collect::<std::collections::BTreeMap<String, u16>>()
2254 })
2255 .unwrap_or_default();
2256
2257 Some(InstalledService {
2258 name: service_name.to_string(),
2259 version: "0.1.0".to_string(),
2260 repo: meta.registry,
2261 ports,
2262 auth_kind,
2263 exposure,
2264 provides: meta.provides,
2265 installed: true,
2266 })
2267}
2268
2269pub fn list_installed() -> Result<Vec<InstalledService>> {
2276 let mut names: std::collections::BTreeSet<String> = scan_managed_services()
2277 .unwrap_or_default()
2278 .into_iter()
2279 .collect();
2280 if let Ok(root) = service_data_root()
2284 && let Ok(entries) = std::fs::read_dir(&root)
2285 {
2286 for entry in entries.flatten() {
2287 if let Some(name) = entry.file_name().to_str()
2288 && !names.contains(name)
2289 && is_service_installed(name)
2290 {
2291 names.insert(name.to_string());
2292 }
2293 }
2294 }
2295 let out: Vec<InstalledService> = names
2296 .iter()
2297 .filter_map(|n| build_installed_from_metadata(n))
2298 .collect();
2299 Ok(out)
2300}
2301
2302pub fn search_services(repo_dir: &Path, query: Option<&str>) -> Result<Vec<SearchResult>> {
2304 let available = registry::list_available(repo_dir)?;
2305
2306 let results = available
2307 .into_iter()
2308 .filter(|reg_svc| match query {
2309 None => true,
2310 Some(q) => {
2311 let q = q.to_lowercase();
2312 reg_svc.def.service.name.to_lowercase().contains(&q)
2313 || reg_svc.def.service.description.to_lowercase().contains(&q)
2314 }
2315 })
2316 .map(|reg_svc| {
2317 let name = ®_svc.def.service.name;
2318 let installed = is_service_installed(name);
2319 let mut supports = Vec::new();
2320 for kind in ®_svc.def.integrations.auth {
2321 supports.push(kind.to_string());
2322 }
2323 if reg_svc.def.integrations.smtp {
2324 supports.push("smtp".to_string());
2325 }
2326 SearchResult {
2327 name: name.clone(),
2328 description: reg_svc.def.service.description,
2329 installed,
2330 supports,
2331 }
2332 })
2333 .collect();
2334
2335 Ok(results)
2336}
2337
2338pub struct SearchResult {
2339 pub name: String,
2340 pub description: String,
2341 pub installed: bool,
2342 pub supports: Vec<String>,
2344}
2345
2346pub async fn service_tests(service_name: &str) -> Result<ServiceTestInfo> {
2348 let installed = build_installed_from_metadata(service_name)
2349 .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
2350
2351 let service_ref = service_ref_from_installed(&installed);
2352 let repo_dir = resolve_registry_dir(&service_ref).await?;
2353
2354 let test_toml_path = repo_dir.join(service_name).join("test.toml");
2355 let env_file = service_home(service_name)?.join(".env");
2356
2357 if !test_toml_path.exists() {
2358 return Ok(ServiceTestInfo {
2359 service_name: service_name.to_string(),
2360 registry_name: service_ref.registry_name().to_string(),
2361 tests: vec![],
2362 env_file,
2363 });
2364 }
2365
2366 let content = std::fs::read_to_string(&test_toml_path).map_err(|source| Error::FileRead {
2367 path: test_toml_path.clone(),
2368 source,
2369 })?;
2370
2371 #[derive(serde::Deserialize)]
2372 struct TestFile {
2373 #[serde(default)]
2374 tests: Vec<registry::test_def::TestDef>,
2375 }
2376
2377 let parsed: TestFile = toml::from_str(&content).map_err(|source| Error::TomlParse {
2378 path: test_toml_path,
2379 source,
2380 })?;
2381
2382 Ok(ServiceTestInfo {
2383 service_name: service_name.to_string(),
2384 registry_name: service_ref.registry_name().to_string(),
2385 tests: parsed.tests,
2386 env_file,
2387 })
2388}
2389
2390pub struct ServiceTestInfo {
2391 pub service_name: String,
2392 pub registry_name: String,
2393 pub tests: Vec<registry::test_def::TestDef>,
2394 pub env_file: PathBuf,
2395}
2396
2397pub fn service_info(repo_dir: &Path, service_name: &str) -> Result<ServiceDetail> {
2399 let reg_service = registry::find_service(repo_dir, service_name)?;
2400 let def = ®_service.def;
2401
2402 Ok(ServiceDetail {
2403 name: def.service.name.clone(),
2404 description: def.service.description.clone(),
2405 url: def.service.url.clone(),
2406 ports: def
2407 .ports
2408 .iter()
2409 .map(|p| (p.container_port, p.protocol.clone(), p.name.clone()))
2410 .collect(),
2411 env_vars: def
2412 .env
2413 .iter()
2414 .map(|e| (e.name.clone(), e.prompt.clone()))
2415 .collect(),
2416 })
2417}
2418
2419pub struct ServiceDetail {
2420 pub name: String,
2421 pub description: String,
2422 pub url: Option<String>,
2423 pub ports: Vec<(u16, registry::service_def::PortProtocol, String)>,
2424 pub env_vars: Vec<(String, Option<String>)>,
2425}
2426
2427#[cfg(test)]
2428mod tests {
2429 use super::*;
2430
2431 #[test]
2432 fn static_template_filter_excludes_secrets_and_credentials() {
2433 assert!(is_static_template("3306"));
2435 assert!(is_static_template("mariadb"));
2436 assert!(is_static_template("{{service.port}}"));
2438 assert!(is_static_template("{{service.url}}"));
2439 assert!(is_static_template("{{auth.url}}"));
2440 assert!(is_static_template("{{auth.issuer}}"));
2441 assert!(is_static_template("{{auth.provider}}"));
2442 assert!(is_static_template("{{auth.internal_url}}"));
2443 assert!(is_static_template("{{smtp.host}}"));
2444 assert!(is_static_template("{{smtp.port}}"));
2445 assert!(is_static_template("{{smtp.from}}"));
2446 assert!(is_static_template("{{service.url}}/oauth/callback"));
2448
2449 assert!(!is_static_template("{{secret.admin_password}}"));
2451 assert!(!is_static_template("{{secret.jwt_key}}"));
2452 assert!(!is_static_template("{{auth.client_id}}"));
2454 assert!(!is_static_template("{{auth.client_secret}}"));
2455 assert!(!is_static_template("{{smtp.username}}"));
2457 assert!(!is_static_template("{{smtp.password}}"));
2458 assert!(!is_static_template(
2460 "redis://:{{secret.redis_pw}}@host:6379"
2461 ));
2462 }
2463
2464 #[test]
2465 fn tailscale_url_matches() {
2466 assert!(is_tailscale_url("http://debian.cobbler-tuna.ts.net"));
2467 assert!(is_tailscale_url("http://debian.cobbler-tuna.ts.net:10001/"));
2468 assert!(is_tailscale_url("https://foo.example-net.ts.net"));
2469 assert!(is_tailscale_url("http://HOST.COBBLER-TUNA.TS.NET"));
2470 }
2471
2472 #[test]
2473 fn tailscale_url_rejects() {
2474 assert!(!is_tailscale_url("https://nextcloud.internal:8443"));
2475 assert!(!is_tailscale_url("https://example.com"));
2476 assert!(!is_tailscale_url("http://127.0.0.1:10001"));
2477 assert!(!is_tailscale_url("https://ts.net"));
2479 assert!(!is_tailscale_url("https://evil-ts.net.example.com"));
2480 assert!(!is_tailscale_url("not a url"));
2481 }
2482
2483 #[test]
2484 fn public_url_accepts_public_domains() {
2485 assert!(is_public_url("https://seafile.ryra.no"));
2486 assert!(is_public_url("https://example.com"));
2487 assert!(is_public_url("https://docs.ryra.no:8443"));
2488 }
2489
2490 #[test]
2491 fn public_url_rejects_lan_and_tailnet() {
2492 assert!(!is_public_url("https://nextcloud.internal:8443"));
2493 assert!(!is_public_url("https://service.localhost"));
2494 assert!(!is_public_url("https://something.local"));
2495 assert!(!is_public_url("https://localhost:8080"));
2496 assert!(!is_public_url("https://debian.cobbler-tuna.ts.net"));
2497 assert!(!is_public_url("http://127.0.0.1:10001"));
2498 assert!(!is_public_url("http://192.168.1.10"));
2499 assert!(!is_public_url("http://[::1]"));
2500 assert!(!is_public_url("not a url"));
2501 }
2502
2503 #[test]
2508 fn networks_empty_when_no_auth() {
2509 let nets = resolve_extra_networks(
2510 "whoami", false, false, false, false, false, false, None, false,
2511 );
2512 assert!(nets.is_empty());
2513 }
2514
2515 #[test]
2516 fn networks_empty_when_auth_but_no_authelia() {
2517 let nets = resolve_extra_networks(
2518 "forgejo", true, false, false, false, false, false, None, false,
2519 );
2520 assert!(nets.is_empty());
2521 }
2522
2523 #[test]
2524 fn networks_authelia_when_auth_enabled() {
2525 let nets = resolve_extra_networks(
2526 "forgejo", true, true, false, false, false, false, None, false,
2527 );
2528 assert_eq!(nets, vec!["authelia"]);
2529 }
2530
2531 #[test]
2532 fn networks_auth_with_caddy_includes_both() {
2533 let nets = resolve_extra_networks(
2534 "forgejo", true, true, true, false, false, false, None, false,
2535 );
2536 assert!(nets.contains(&"authelia".to_string()));
2537 assert!(nets.contains(&"caddy".to_string()));
2538 }
2539
2540 #[test]
2541 fn networks_authelia_excluded_for_authelia_itself() {
2542 let nets = resolve_extra_networks(
2543 "authelia", true, true, false, false, false, false, None, false,
2544 );
2545 assert!(nets.is_empty());
2546 }
2547
2548 #[test]
2549 fn networks_smtp_joins_inbucket_without_caddy() {
2550 let nets = resolve_extra_networks(
2552 "forgejo", false, false, false, true, false, true, None, false,
2553 );
2554 assert_eq!(nets, vec!["inbucket"]);
2555 }
2556
2557 #[test]
2558 fn networks_smtp_skips_inbucket_when_it_is_self() {
2559 let nets = resolve_extra_networks(
2560 "inbucket", false, false, false, true, false, true, None, false,
2561 );
2562 assert!(!nets.contains(&"inbucket".to_string()));
2563 }
2564
2565 #[test]
2566 fn networks_smtp_skips_inbucket_when_not_installed() {
2567 let nets = resolve_extra_networks(
2568 "forgejo", false, false, false, false, false, true, None, false,
2569 );
2570 assert!(!nets.contains(&"inbucket".to_string()));
2571 }
2572
2573 #[test]
2574 fn networks_metrics_consumer_joins_store() {
2575 let nets = resolve_extra_networks(
2576 "grafana",
2577 false,
2578 false,
2579 false,
2580 false,
2581 false,
2582 false,
2583 Some("prometheus"),
2584 true,
2585 );
2586 assert_eq!(nets, vec!["prometheus".to_string()]);
2587 }
2588
2589 #[test]
2590 fn networks_metrics_store_skips_itself() {
2591 let nets = resolve_extra_networks(
2592 "prometheus",
2593 false,
2594 false,
2595 false,
2596 false,
2597 false,
2598 false,
2599 Some("prometheus"),
2600 true,
2601 );
2602 assert!(nets.is_empty());
2603 }
2604
2605 #[test]
2606 fn networks_metrics_indifferent_service_skips_store() {
2607 let nets = resolve_extra_networks(
2608 "vaultwarden",
2609 false,
2610 false,
2611 false,
2612 false,
2613 false,
2614 false,
2615 Some("prometheus"),
2616 false,
2617 );
2618 assert!(nets.is_empty());
2619 }
2620
2621 #[test]
2622 fn quadlet_belongs_to_exact_match() {
2623 let all = &["foo", "foo-bar"];
2624 assert!(quadlet_belongs_to("foo.container", "foo", all));
2625 assert!(quadlet_belongs_to("foo.network", "foo", all));
2626 }
2627
2628 #[test]
2629 fn quadlet_belongs_to_sidecar() {
2630 let all = &["foo"];
2632 assert!(quadlet_belongs_to("foo-db.volume", "foo", all));
2633 }
2634
2635 #[test]
2636 fn quadlet_belongs_to_rejects_prefix_collision() {
2637 let all = &["foo", "foo-bar"];
2638 assert!(!quadlet_belongs_to("foo-bar.container", "foo", all));
2639 assert!(!quadlet_belongs_to("foo-bar-db.volume", "foo", all));
2640 }
2641
2642 #[test]
2643 fn quadlet_belongs_to_hyphenated_service() {
2644 let all = &["foo", "foo-bar"];
2645 assert!(quadlet_belongs_to("foo-bar.container", "foo-bar", all));
2646 assert!(quadlet_belongs_to("foo-bar-db.volume", "foo-bar", all));
2647 assert!(!quadlet_belongs_to("foo.container", "foo-bar", all));
2648 }
2649}