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