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 paths;
15pub mod plan;
16pub mod registry;
17pub mod system;
18pub mod upgrade;
19pub mod well_known;
20
21use std::collections::BTreeMap;
22use std::path::{Path, PathBuf};
23
24use config::ConfigPaths;
25use config::schema::InstalledService;
26use error::{Error, Result};
27
28pub use capability::{
29 Capability, any_installed_provider, find_installed_provider, installed_provides,
30 service_provides,
31};
32pub use configure::{
33 ConfigureChange, ConfigureResult, ExposureChange, Overrides as ConfigureOverrides,
34 configure_service,
35};
36pub use exposure::{Exposure, is_public_url, is_tailscale_url};
37pub use generate::GeneratedFile;
38pub use manifest::{ManifestEntry, manifest_path};
39pub use metadata::{Metadata, load_metadata};
40pub use paths::{
41 DEFAULT_REGISTRY_URL, REGISTRY_DEFAULT, REGISTRY_DIR_ENV, metadata_path, quadlet_dir,
42 service_data_root, service_home,
43};
44pub use plan::{AddResult, RemoveResult, ResetResult, Step, TrackedEnv, Warning};
45pub use upgrade::{
46 BackupSnapshot, DEFAULT_BACKUP_KEEP, DiffEntry, DiffKind, DiffResult, EnvAddition,
47 RevertResult, UpgradeResult, diff_service, list_backups, prune_backups, revert_service,
48 upgrade_service,
49};
50pub use well_known::WellKnownService;
51
52pub(crate) use paths::home_dir;
53pub(crate) use well_known::caddy_https_port;
54
55pub async fn resolve_registry_dir(service_ref: ®istry::resolve::ServiceRef) -> Result<PathBuf> {
57 let paths = ConfigPaths::resolve()?;
58 paths.ensure_cache_dir()?;
59 let config = config::load_or_default(&paths.config_file)?;
60 registry::resolve::resolve_registry_dir(service_ref, &config, &paths.cache_dir).await
61}
62
63pub fn service_ref_from_installed(installed: &InstalledService) -> registry::resolve::ServiceRef {
65 if installed.repo.is_empty() || installed.repo == REGISTRY_DEFAULT {
66 registry::resolve::ServiceRef::Default(installed.name.clone())
67 } else {
68 registry::resolve::ServiceRef::Custom {
69 registry: installed.repo.clone(),
70 service: installed.name.clone(),
71 }
72 }
73}
74
75fn retroactive_network_joins(
84 new_service: &str,
85 quadlet_path: &std::path::Path,
86 _repo_dir: Option<&std::path::Path>,
87) -> Vec<Step> {
88 let mut steps = Vec::new();
89 let new_cap = if service_provides(new_service, Capability::ReverseProxy) {
94 Capability::ReverseProxy
95 } else if service_provides(new_service, Capability::SmtpRelay) {
96 Capability::SmtpRelay
97 } else {
98 return steps;
99 };
100
101 let installed = list_installed().unwrap_or_default();
102 for svc in &installed {
103 if !svc.provides.is_empty() {
106 continue;
107 }
108 let (network_name, should_join) = match new_cap {
109 Capability::ReverseProxy => {
110 let wants_proxy = matches!(
114 svc.exposure,
115 Exposure::Internal { .. } | Exposure::Public { .. }
116 );
117 (new_service.to_string(), wants_proxy)
118 }
119 Capability::SmtpRelay => {
120 (
123 new_service.to_string(),
124 service_uses_smtp_relay(&svc.name, new_service),
125 )
126 }
127 Capability::OidcProvider | Capability::ForwardAuthProvider => {
131 continue;
132 }
133 };
134 if !should_join {
135 continue;
136 }
137 let installed_names_owned: Vec<String> = installed.iter().map(|s| s.name.clone()).collect();
144 let all_service_names: Vec<&str> =
145 installed_names_owned.iter().map(|s| s.as_str()).collect();
146 let marker = format!("Network={network_name}.network");
147 let mut units_to_restart: Vec<String> = Vec::new();
148 let Ok(entries) = std::fs::read_dir(quadlet_path) else {
149 continue;
150 };
151 for entry in entries.flatten() {
152 let path = entry.path();
153 let name = match path.file_name().and_then(|n| n.to_str()) {
154 Some(n) if n.ends_with(".container") => n.to_string(),
155 _ => continue,
156 };
157 if !quadlet_belongs_to(&name, &svc.name, &all_service_names) {
158 continue;
159 }
160 let content = match std::fs::read_to_string(&path) {
161 Ok(c) => c,
162 Err(_) => continue,
163 };
164 if content.contains(&marker) {
165 continue;
166 }
167 let updated =
168 generate::bundle::inject_networks(&content, std::slice::from_ref(&network_name));
169 steps.push(Step::WriteFile(GeneratedFile {
170 path,
171 content: updated,
172 }));
173 let unit = name.trim_end_matches(".container").to_string();
176 units_to_restart.push(unit);
177 }
178 if !units_to_restart.is_empty() {
179 steps.push(Step::DaemonReload);
180 for unit in units_to_restart {
181 steps.push(Step::RestartService { unit });
182 }
183 }
184 }
185 steps
186}
187
188fn service_uses_smtp_relay(service_name: &str, relay_host: &str) -> bool {
194 let env_path = match service_home(service_name) {
195 Ok(h) => h.join(".env"),
196 Err(_) => return false,
197 };
198 let content = match std::fs::read_to_string(&env_path) {
199 Ok(c) => c,
200 Err(_) => return false,
201 };
202 let with_port = format!("{relay_host}:");
203 content.lines().any(|line| {
204 let Some((_, value)) = line.split_once('=') else {
205 return false;
206 };
207 let v = value.trim();
208 v == relay_host || v.starts_with(&with_port)
209 })
210}
211
212#[allow(clippy::too_many_arguments)]
224fn resolve_extra_networks(
225 service_name: &str,
226 enable_auth: bool,
227 authelia_installed: bool,
228 caddy_installed: bool,
229 inbucket_installed: bool,
230 has_url: bool,
231 has_smtp: bool,
232) -> Vec<String> {
233 let mut networks = Vec::new();
234 if enable_auth && authelia_installed && !WellKnownService::Authelia.matches(service_name) {
235 networks.push(WellKnownService::Authelia.to_string());
236 }
237 let joins_inbucket =
240 has_smtp && inbucket_installed && !WellKnownService::Inbucket.matches(service_name);
241 if joins_inbucket {
242 networks.push(WellKnownService::Inbucket.to_string());
243 }
244 let joins_caddy = (has_url || enable_auth || WellKnownService::Inbucket.matches(service_name))
245 && caddy_installed
246 && !WellKnownService::Caddy.matches(service_name);
247 if joins_caddy && !networks.contains(&WellKnownService::Caddy.to_string()) {
248 networks.push(WellKnownService::Caddy.to_string());
249 }
250 networks
251}
252
253#[derive(Debug, Clone, Copy, PartialEq, Eq)]
260pub enum PlanMode {
261 Add,
265 Upgrade,
269}
270
271#[allow(clippy::too_many_arguments)]
277pub fn add_service(
278 service_name: &str,
279 exposure: &Exposure,
280 auth_kind: Option<registry::service_def::AuthKind>,
281 enable_auth: bool,
282 enable_smtp: bool,
283 enable_backup: bool,
284 env_overrides: &BTreeMap<String, String>,
285 enabled_groups: &std::collections::BTreeSet<String>,
286 registry_name: &str,
287 repo_dir: &Path,
288 pre_built_ctx: Option<BTreeMap<String, String>>,
289 port_in_use: &dyn Fn(u16) -> bool,
290 acme_mode: Option<&caddy::AcmeMode>,
291 mode: PlanMode,
292 port_overrides: &BTreeMap<String, u16>,
298) -> Result<AddResult> {
299 let url: Option<&str> = exposure.url();
306 let tailscale_enabled: bool = exposure.is_tailscale();
307 let paths = ConfigPaths::resolve()?;
308 let config = config::load_or_default(&paths.config_file)?;
309
310 if mode == PlanMode::Add {
316 if is_service_installed(service_name) {
317 return Err(Error::ServiceAlreadyInstalled(service_name.to_string()));
318 }
319
320 if data::enumerate_service(service_name)?.is_some() {
328 return Err(Error::ServiceIncomplete(service_name.to_string()));
329 }
330 }
331
332 let reg_service = registry::find_service(repo_dir, service_name)?;
333
334 if let Some(msg) = reg_service.def.check_architecture() {
336 return Err(Error::UnsupportedArchitecture(msg));
337 }
338
339 let missing_requires: Vec<&str> = reg_service
341 .def
342 .requires
343 .iter()
344 .filter(|r| !is_service_installed(&r.service))
345 .map(|r| r.service.as_str())
346 .collect();
347 if !missing_requires.is_empty() {
348 return Err(Error::MissingRequiredServices {
349 service: service_name.to_string(),
350 missing: missing_requires.iter().map(|s| s.to_string()).collect(),
351 });
352 }
353
354 if auth_kind.is_some() && config.auth.is_none() {
356 return Err(Error::AuthNotConfigured);
357 }
358
359 if enable_auth
363 && reg_service.def.integrations.auth.is_empty()
364 && !capability::def_provides(®_service.def, Capability::OidcProvider)
365 {
366 return Err(Error::NoOidcSupport(service_name.to_string()));
367 }
368
369 if enable_backup && !reg_service.def.integrations.backup {
373 return Err(Error::BackupNotSupported(service_name.to_string()));
374 }
375
376 for g in enabled_groups {
380 if !reg_service.def.env_groups.iter().any(|eg| &eg.name == g) {
381 let known: Vec<String> = reg_service
382 .def
383 .env_groups
384 .iter()
385 .map(|eg| eg.name.clone())
386 .collect();
387 let hint = if known.is_empty() {
388 " (service defines no env_groups)".to_string()
389 } else {
390 format!(" (known: {})", known.join(", "))
391 };
392 return Err(Error::UnknownEnvGroup {
393 service: service_name.to_string(),
394 group: g.clone(),
395 hint,
396 });
397 }
398 }
399
400 let mut port_warnings: Vec<Warning> = Vec::new();
406 let mut claimed: std::collections::HashSet<u16> = reg_service
407 .def
408 .ports
409 .iter()
410 .filter_map(|p| p.host_port)
411 .collect();
412 let mut resolved_ports: Vec<(String, u16)> = Vec::with_capacity(reg_service.def.ports.len());
413 for p in ®_service.def.ports {
414 let host = if let Some(pinned) = port_overrides.get(&p.name) {
415 *pinned
420 } else if let Some(hp) = p.host_port {
421 hp
422 } else {
423 let privileged = p.container_port < 1024;
424 let claimed_in_service = claimed.contains(&p.container_port);
425 let in_use = port_in_use(p.container_port);
426 if privileged || claimed_in_service || in_use {
427 let allocated = system::port::allocate_port_excluding(&claimed, port_in_use)?;
428 let reason = if privileged {
429 "port is privileged (requires root)".to_string()
430 } else if claimed_in_service {
431 format!(
432 "port {} is already claimed by another port in this service",
433 p.container_port
434 )
435 } else {
436 format!("port {} is already in use", p.container_port)
437 };
438 port_warnings.push(Warning::PortReassigned {
439 service_name: service_name.to_string(),
440 port_name: p.name.clone(),
441 original_port: p.container_port,
442 assigned_port: allocated,
443 reason,
444 });
445 allocated
446 } else {
447 p.container_port
448 }
449 };
450 claimed.insert(host);
451 resolved_ports.push((p.name.clone(), host));
452 }
453
454 if WellKnownService::Caddy.matches(service_name)
461 && system::sysctl::rootless_can_bind_low_ports()
462 {
463 for (name, port) in resolved_ports.iter_mut() {
464 match name.as_str() {
465 "http" if *port == 8080 => *port = 80,
466 "https" if *port == 8443 => *port = 443,
467 _ => {}
468 }
469 }
470 }
471
472 let host_port = resolved_ports
475 .iter()
476 .find(|(name, _)| name.eq_ignore_ascii_case("http"))
477 .or_else(|| resolved_ports.first())
478 .map(|(_, p)| *p);
479
480 for (_, port) in &resolved_ports {
484 if port_in_use(*port) {
485 return Err(Error::PortConflict { port: *port });
486 }
487 }
488
489 let home_dir = service_home(service_name)?;
490 let quadlet_path = quadlet_dir()?;
491
492 let installed_now = list_installed().unwrap_or_default();
496 let authelia_installed =
497 find_installed_provider(&installed_now, Capability::OidcProvider).is_some();
498 let caddy_installed =
499 find_installed_provider(&installed_now, Capability::ReverseProxy).is_some();
500 let inbucket_installed =
501 find_installed_provider(&installed_now, Capability::SmtpRelay).is_some();
502
503 let auth_bridge = auth_bridge::build(&auth_bridge::AuthBridgeParams {
507 service_name,
508 service_provides: ®_service.def.capabilities.provides,
509 enable_auth,
510 config: &config,
511 installed: &installed_now,
512 service_data: &home_dir,
513 })?;
514
515 let (extra_volumes, extra_env, extra_exec_start_pre, auth_bridge_steps) = match auth_bridge {
516 Some(b) => (b.volumes, b.env, b.exec_start_pre, b.steps),
517 None => (Vec::new(), BTreeMap::new(), Vec::new(), Vec::new()),
518 };
519
520 let has_smtp = enable_smtp
521 && reg_service.def.integrations.smtp
522 && !reg_service.def.mappings.smtp.is_empty()
523 && config.smtp.is_some();
524 let extra_networks = resolve_extra_networks(
525 service_name,
526 enable_auth,
527 authelia_installed,
528 caddy_installed,
529 inbucket_installed,
530 url.is_some(),
531 has_smtp,
532 );
533
534 let output = generate::generate_env(generate::GenerateEnvParams {
535 config: &config,
536 service_def: ®_service.def,
537 auth_kind: auth_kind.as_ref(),
538 host_port,
539 resolved_ports: &resolved_ports,
540 env_overrides,
541 url,
542 extra_env,
543 pre_built_ctx,
544 enable_smtp: has_smtp,
545 enabled_groups,
546 })?;
547
548 let podman_args: Vec<String> = Vec::new();
549
550 let port_vars: Vec<(String, String)> = resolved_ports
553 .iter()
554 .map(|(name, port)| {
555 (
556 format!("SERVICE_PORT_{}", name.to_uppercase()),
557 port.to_string(),
558 )
559 })
560 .collect();
561
562 let install_metadata = Metadata {
575 registry: registry_name.to_string(),
576 url: url.map(str::to_string),
577 auth: auth_kind.clone(),
578 provides: reg_service.def.capabilities.provides.clone(),
579 backup_enabled: enable_backup,
580 smtp_enabled: enable_smtp,
581 enabled_groups: enabled_groups.iter().cloned().collect(),
582 };
583
584 let bundle =
586 generate::bundle::process_quadlet_bundle(&generate::bundle::ProcessBundleParams {
587 service_dir: ®_service.service_dir,
588 service_name,
589 extra_networks: &extra_networks,
590 extra_volumes: &extra_volumes,
591 podman_args: &podman_args,
592 extra_exec_start_pre: &extra_exec_start_pre,
593 port_vars: &port_vars,
594 })?;
595
596 let mut warnings = Vec::new();
598
599 if let Some(ref reqs) = reg_service.def.requirements
600 && let Some(total) = system::memory::total_ram_mb()
601 {
602 if total < reqs.ram.min {
603 warnings.push(Warning::RamBelowMinimum {
604 service_name: service_name.to_string(),
605 min_mb: reqs.ram.min,
606 available_mb: total,
607 });
608 } else if let Some(rec) = reqs.ram.recommended
609 && total < rec
610 {
611 warnings.push(Warning::RamBelowRecommended {
612 service_name: service_name.to_string(),
613 recommended_mb: rec,
614 available_mb: total,
615 });
616 }
617 }
618 warnings.extend(port_warnings);
619
620 let mut steps = Vec::new();
622
623 steps.push(Step::CreateDir(home_dir.clone()));
625
626 let env_content = output.env_file.content.clone();
628
629 for image in &bundle.images {
631 steps.push(Step::PullImage {
632 image: image.clone(),
633 });
634 }
635
636 for file in bundle.quadlet_files {
640 let link = file
641 .path
642 .file_name()
643 .map(|n| quadlet_path.join(n))
644 .ok_or_else(|| {
645 Error::Bundle(format!("invalid quadlet path: {}", file.path.display()))
646 })?;
647 let target = file.path.clone();
648 steps.push(Step::WriteFile(file));
649 steps.push(Step::Symlink { link, target });
650 }
651
652 let metadata_content = toml::to_string_pretty(&install_metadata)?;
659 steps.push(Step::WriteFile(GeneratedFile {
660 path: metadata_path(service_name)?,
661 content: metadata_content,
662 }));
663
664 if mode == PlanMode::Add
672 && tailscale_enabled
673 && let Some(port) = host_port
674 {
675 let svc_name = exposure.tailscale_svc_name().ok_or_else(|| {
684 Error::InvalidServiceRef(format!(
685 "tailscale exposure for '{service_name}' has a malformed URL — \
686 expected `https://<service>-<host>.<tailnet>.ts.net/`"
687 ))
688 })?;
689 steps.push(Step::TailscaleSetup);
690 steps.push(Step::TailscaleEnable {
691 svc_name,
692 host_port: port,
693 });
694 }
695
696 for file in bundle.config_files {
698 steps.push(Step::WriteFile(file));
699 }
700
701 for (src, dst) in bundle.files {
705 steps.push(Step::CopyFile { src, dst });
706 }
707
708 steps.push(Step::WriteFile(output.env_file));
710
711 for dir in &bundle.bind_mount_dirs {
713 steps.push(Step::CreateDir(dir.clone()));
714 }
715
716 steps.extend(auth_bridge_steps);
720
721 if mode == PlanMode::Add
730 && let (
731 Some(registry::service_def::AuthKind::Oidc),
732 Some(config::schema::AuthCredentials::Authelia { .. }),
733 ) = (auth_kind.as_ref(), config.auth.as_ref())
734 {
735 steps.extend(authelia::register_oidc_client(
736 service_name,
737 ®_service.def,
738 url,
739 &output.ctx,
740 &config,
741 &quadlet_path,
742 )?);
743 }
744
745 if let Some(url) = url
751 && !WellKnownService::Caddy.matches(service_name)
752 && !exposure.is_tailscale()
753 {
754 if caddy_installed {
755 let parsed = url::Url::parse(url)
756 .map_err(|e| Error::Template(format!("invalid service URL '{url}': {e}")))?;
757 let domain = parsed.host_str().ok_or_else(|| {
758 Error::Template(format!(
759 "service URL '{url}' has no host — Caddy needs a hostname to route to"
760 ))
761 })?;
762 let container_port = reg_service
763 .def
764 .ports
765 .first()
766 .map(|p| p.container_port)
767 .unwrap_or(80);
768 let primary_quadlet = reg_service
769 .service_dir
770 .join("quadlets")
771 .join(format!("{service_name}.container"));
772 let target_host = caddy::primary_container_name(&primary_quadlet, service_name);
773 let block = caddy::render_site_block(&caddy::CaddySiteParams {
774 service_name: service_name.to_string(),
775 target_host,
776 domain: domain.to_string(),
777 container_port,
778 https_port: caddy_https_port(&config),
779 });
780 let caddyfile_path = caddy::caddyfile_path()?;
781 let existing =
782 std::fs::read_to_string(&caddyfile_path).map_err(|source| Error::FileRead {
783 path: caddyfile_path.clone(),
784 source,
785 })?;
786 let updated = caddy::add_route(&existing, service_name, &block);
787 steps.push(Step::WriteFile(GeneratedFile {
788 path: caddyfile_path,
789 content: updated,
790 }));
791 steps.push(Step::ReloadCaddy);
792 } else if let Some(primary) = host_port {
793 warnings.push(Warning::UrlWithoutReverseProxy {
798 service_name: service_name.to_string(),
799 url: url.to_string(),
800 host_port: primary,
801 });
802 }
803 }
804
805 if mode == PlanMode::Add {
815 steps.extend(retroactive_network_joins(
816 service_name,
817 &quadlet_path,
818 Some(repo_dir),
819 ));
820 }
821
822 if WellKnownService::Caddy.matches(service_name) {
829 let snippet_path = caddy::tls_snippet_path()?;
830 if !snippet_path.exists() {
831 let mode = acme_mode.cloned().unwrap_or(caddy::AcmeMode::Internal);
832 steps.push(Step::WriteFile(GeneratedFile {
833 path: snippet_path,
834 content: mode.snippet(),
835 }));
836 }
837 }
838
839 let manifest_path_for_svc = manifest::manifest_path(service_name)?;
848 let env_filename = std::ffi::OsStr::new(".env");
849 let mut manifest_entries: Vec<manifest::ManifestEntry> = Vec::new();
850 for step in &steps {
851 if let Step::WriteFile(file) = step {
852 if file.path == manifest_path_for_svc {
853 continue;
854 }
855 if file.path.file_name() == Some(env_filename) {
856 continue;
857 }
858 manifest_entries.push(manifest::ManifestEntry {
859 path: file.path.clone(),
860 sha256: manifest::hash_bytes(file.content.as_bytes()),
861 });
862 }
863 }
864 let tracked_envs = collect_static_envs(®_service.def, &output.ctx, enabled_groups)?;
872 let manifest_envs: Vec<manifest::EnvEntry> = tracked_envs
873 .iter()
874 .map(|t| manifest::EnvEntry {
875 key: t.key.clone(),
876 value: t.value.clone(),
877 })
878 .collect();
879 steps.push(Step::WriteFile(GeneratedFile {
880 path: manifest_path_for_svc,
881 content: manifest::format(&manifest_entries, &manifest_envs),
882 }));
883
884 steps.push(Step::DaemonReload);
886 steps.push(Step::StartService {
888 unit: service_name.to_string(),
889 });
890
891 let allocated_ports: Vec<(String, u16)> = resolved_ports.clone();
893
894 let mut generated_secrets: Vec<String> = reg_service
896 .def
897 .env
898 .iter()
899 .filter(|e| !env_overrides.contains_key(&e.name))
900 .flat_map(|e| generate::extract_secret_refs(&e.value))
901 .collect();
902 generated_secrets.sort();
904 generated_secrets.dedup();
905
906 Ok(AddResult {
907 steps,
908 warnings,
909 repo_url: registry_name.to_string(),
910 allocated_ports,
911 generated_secrets,
912 env_content,
913 url: url.map(|u| u.to_string()),
914 tracked_envs,
915 })
916}
917
918pub fn quadlet_belongs_to(filename: &str, service_name: &str, all_service_names: &[&str]) -> bool {
927 if !filename.starts_with(service_name) {
928 return false;
929 }
930 let rest = &filename[service_name.len()..];
931 if rest.starts_with('.') {
932 return true;
933 }
934 if !rest.starts_with('-') {
935 return false;
936 }
937 !all_service_names.iter().any(|&other| {
941 other.len() > service_name.len()
942 && other.starts_with(service_name)
943 && filename.starts_with(other)
944 && filename[other.len()..].starts_with(['.', '-'])
945 })
946}
947
948#[derive(Debug, Clone, Copy, PartialEq, Eq)]
950pub enum RemoveMode {
951 Preserve,
956 Purge,
958}
959
960pub fn remove_service(service_name: &str, mode: RemoveMode) -> Result<RemoveResult> {
962 let installed_owned = build_installed_from_metadata(service_name)
965 .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
966 let installed = &installed_owned;
967
968 let quadlet_path = quadlet_dir()?;
971 let mut steps = Vec::new();
972 let mut volume_names = Vec::new();
973 let mut has_named_volumes = false;
974 let name_pool = scan_managed_services().unwrap_or_default();
978 let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
979
980 if let Some(svc_name) = installed.exposure.tailscale_svc_name() {
992 steps.push(Step::TailscaleDisable { svc_name });
993 }
994
995 if quadlet_path.is_dir()
996 && let Ok(entries) = std::fs::read_dir(&quadlet_path)
997 {
998 for entry in entries.flatten() {
999 let file_name = entry.file_name();
1000 let name = file_name.to_string_lossy();
1001 if !quadlet_belongs_to(&name, service_name, &all_names) {
1004 continue;
1005 }
1006 if name.ends_with(".container") {
1008 let unit = name.trim_end_matches(".container").to_string();
1009 steps.push(Step::StopService { unit });
1010 }
1011 if name.ends_with(".volume") {
1012 has_named_volumes = true;
1013 if matches!(mode, RemoveMode::Purge) {
1014 let vol = name.trim_end_matches(".volume").to_string();
1015 volume_names.push(format!("systemd-{vol}"));
1017 }
1018 }
1019 steps.push(Step::RemoveFile(entry.path()));
1020 }
1021 }
1022
1023 let had_caddy_route = matches!(
1030 installed.exposure,
1031 Exposure::Internal { .. } | Exposure::Public { .. }
1032 );
1033 if !WellKnownService::Caddy.matches(service_name) && had_caddy_route {
1034 let caddyfile_path = caddy::caddyfile_path()?;
1035 if caddyfile_path.exists() {
1036 let existing =
1037 std::fs::read_to_string(&caddyfile_path).map_err(|source| Error::FileRead {
1038 path: caddyfile_path.clone(),
1039 source,
1040 })?;
1041 let updated = caddy::remove_route(&existing, service_name);
1042 if updated != existing {
1043 steps.push(Step::WriteFile(GeneratedFile {
1044 path: caddyfile_path,
1045 content: updated.clone(),
1046 }));
1047 if !updated.trim().is_empty() {
1050 steps.push(Step::ReloadCaddy);
1051 }
1052 }
1053 }
1054 }
1055
1056 if !WellKnownService::Authelia.matches(service_name)
1057 && matches!(
1058 installed.auth_kind,
1059 Some(registry::service_def::AuthKind::Oidc)
1060 )
1061 {
1062 steps.extend(authelia::unregister_oidc_client(service_name)?);
1063 }
1064
1065 steps.push(Step::DaemonReload);
1067
1068 match mode {
1069 RemoveMode::Purge => {
1070 for vol_name in volume_names {
1072 steps.push(Step::RemoveVolume { name: vol_name });
1073 }
1074 steps.push(Step::RemoveDir(service_home(service_name)?));
1076 }
1077 RemoveMode::Preserve => {
1078 let home = service_home(service_name)?;
1082 let (data, ephemeral) = crate::data::classify::classify_home_dir(&home)?;
1083 for path in ephemeral {
1084 match std::fs::metadata(&path) {
1085 Ok(m) if m.is_dir() => steps.push(Step::RemoveDir(path)),
1086 Ok(_) => steps.push(Step::RemoveFile(path)),
1087 Err(_) => steps.push(Step::RemoveFile(path)),
1091 }
1092 }
1093 if data.is_empty() && !has_named_volumes && home.exists() {
1101 steps.push(Step::RemoveDir(home));
1102 }
1103 }
1104 }
1105
1106 let url = installed.exposure.url().map(|s| s.to_string());
1107
1108 Ok(RemoveResult {
1109 steps,
1110 service_name: service_name.to_string(),
1111 url,
1112 })
1113}
1114
1115pub struct RecordPendingParams<'a> {
1117 pub service_name: &'a str,
1118 pub auth_kind: Option<registry::service_def::AuthKind>,
1119 pub registry_name: &'a str,
1120 pub allocated_ports: &'a [(String, u16)],
1121 pub repo_dir: &'a Path,
1122 pub exposure: &'a Exposure,
1129}
1130
1131pub fn record_pending(params: RecordPendingParams<'_>) -> Result<()> {
1138 let paths = ConfigPaths::resolve()?;
1139 paths.ensure_dirs()?;
1140 let mut config = config::load_or_default(&paths.config_file)?;
1141
1142 if WellKnownService::Authelia.matches(params.service_name) {
1147 config.auth = Some(authelia::auth_config(
1148 params.allocated_ports,
1149 params.exposure.url(),
1150 )?);
1151 config::save_config(&paths.config_file, &config)?;
1152 }
1153
1154 Ok(())
1155}
1156
1157pub fn finalize_remove(service_name: &str) -> Result<()> {
1164 let paths = ConfigPaths::resolve()?;
1165 let mut config = config::load_or_default(&paths.config_file)?;
1166
1167 if WellKnownService::Authelia.matches(service_name)
1168 && let Some(auth) = &config.auth
1169 && auth.provider_name() == "authelia"
1170 {
1171 config.auth = None;
1172 config::save_config(&paths.config_file, &config)?;
1173 }
1174
1175 Ok(())
1176}
1177
1178const SENSITIVE_TEMPLATE_REFS: &[&str] = &[
1197 "{{secret.",
1198 "{{auth.client_id",
1199 "{{auth.client_secret",
1200 "{{smtp.username",
1201 "{{smtp.password",
1202];
1203
1204fn is_static_template(value: &str) -> bool {
1205 !SENSITIVE_TEMPLATE_REFS.iter().any(|s| value.contains(s))
1206}
1207
1208fn collect_static_envs(
1224 service_def: ®istry::service_def::ServiceDef,
1225 ctx: &BTreeMap<String, String>,
1226 enabled_groups: &std::collections::BTreeSet<String>,
1227) -> Result<Vec<plan::TrackedEnv>> {
1228 use registry::service_def::EnvKind;
1229 let mut out: Vec<plan::TrackedEnv> = Vec::new();
1230 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
1231 let push = |name: &str,
1232 value_template: &str,
1233 kind: EnvKind,
1234 prompt: Option<String>,
1235 out: &mut Vec<plan::TrackedEnv>,
1236 seen: &mut std::collections::HashSet<String>|
1237 -> Result<()> {
1238 if !is_static_template(value_template) {
1239 return Ok(());
1240 }
1241 if !seen.insert(name.to_string()) {
1242 return Ok(());
1243 }
1244 let value = generate::template::render(value_template, ctx)?;
1245 out.push(plan::TrackedEnv {
1246 key: name.to_string(),
1247 value,
1248 kind,
1249 prompt,
1250 });
1251 Ok(())
1252 };
1253 for env in &service_def.env {
1254 push(
1255 &env.name,
1256 &env.value,
1257 env.kind.clone(),
1258 env.prompt.clone(),
1259 &mut out,
1260 &mut seen,
1261 )?;
1262 }
1263 for group in &service_def.env_groups {
1264 if !enabled_groups.contains(&group.name) {
1265 continue;
1266 }
1267 for env in &group.env {
1268 push(
1269 &env.name,
1270 &env.value,
1271 env.kind.clone(),
1272 env.prompt.clone(),
1273 &mut out,
1274 &mut seen,
1275 )?;
1276 }
1277 }
1278 if service_def.integrations.smtp && ctx.contains_key("smtp.host") {
1284 for (env_name, value_template) in &service_def.mappings.smtp {
1285 push(
1286 env_name,
1287 value_template,
1288 EnvKind::Default,
1289 None,
1290 &mut out,
1291 &mut seen,
1292 )?;
1293 }
1294 }
1295 if ctx.contains_key("auth.client_id") {
1296 for (env_name, value_template) in &service_def.mappings.auth {
1297 push(
1298 env_name,
1299 value_template,
1300 EnvKind::Default,
1301 None,
1302 &mut out,
1303 &mut seen,
1304 )?;
1305 }
1306 }
1307 Ok(out)
1308}
1309
1310pub fn orphan_purge_steps(svc: &data::ServiceData) -> Vec<Step> {
1311 let mut steps = Vec::new();
1312
1313 let mut had_quadlet = false;
1319 if let Ok(qdir) = quadlet_dir()
1320 && qdir.is_dir()
1321 && let Ok(entries) = std::fs::read_dir(&qdir)
1322 {
1323 let name_pool = scan_managed_services().unwrap_or_default();
1324 let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
1325 for entry in entries.flatten() {
1326 let file_name = entry.file_name();
1327 let name = file_name.to_string_lossy();
1328 if !quadlet_belongs_to(&name, &svc.service, &all_names) {
1329 continue;
1330 }
1331 if name.ends_with(".container") {
1335 let unit = name.trim_end_matches(".container").to_string();
1336 steps.push(Step::StopService { unit });
1337 } else if name.ends_with(".network") {
1338 let unit = format!("{}-network", name.trim_end_matches(".network"));
1339 steps.push(Step::StopService { unit });
1340 } else if name.ends_with(".volume") {
1341 let unit = format!("{}-volume", name.trim_end_matches(".volume"));
1342 steps.push(Step::StopService { unit });
1343 }
1344 steps.push(Step::RemoveFile(entry.path()));
1345 had_quadlet = true;
1346 }
1347 }
1348 if had_quadlet {
1349 steps.push(Step::DaemonReload);
1350 }
1351
1352 for path in &svc.data_paths {
1353 if path.is_dir() {
1354 steps.push(Step::RemoveDir(path.clone()));
1355 } else {
1356 steps.push(Step::RemoveFile(path.clone()));
1357 }
1358 }
1359 if svc.home_dir.exists() {
1360 steps.push(Step::RemoveDir(svc.home_dir.clone()));
1361 }
1362 for v in &svc.volumes {
1363 steps.push(Step::RemoveVolume {
1364 name: v.name.clone(),
1365 });
1366 }
1367 steps
1368}
1369
1370pub fn reset() -> Result<ResetResult> {
1372 let mut steps = Vec::new();
1373
1374 let managed_names = scan_managed_services().unwrap_or_default();
1379
1380 for svc in list_installed().unwrap_or_default() {
1387 if let Some(svc_name) = svc.exposure.tailscale_svc_name() {
1388 steps.push(Step::TailscaleDisable { svc_name });
1389 }
1390 }
1391
1392 let quadlet_path = quadlet_dir()?;
1394 let all_names: Vec<&str> = managed_names.iter().map(|s| s.as_str()).collect();
1395 if quadlet_path.is_dir()
1396 && let Ok(entries) = std::fs::read_dir(&quadlet_path)
1397 {
1398 for entry in entries.flatten() {
1399 let file_name = entry.file_name();
1400 let name = file_name.to_string_lossy();
1401 let is_ryra_file = managed_names
1405 .iter()
1406 .any(|svc| quadlet_belongs_to(&name, svc, &all_names));
1407 if !is_ryra_file {
1408 continue;
1409 }
1410 if name.ends_with(".container") {
1411 let unit = name.trim_end_matches(".container").to_string();
1412 steps.push(Step::StopService { unit });
1413 }
1414 if name.ends_with(".network") {
1415 let unit = format!("{}-network", name.trim_end_matches(".network"));
1416 steps.push(Step::StopService { unit });
1417 }
1418 if name.ends_with(".volume") {
1419 let vol = name.trim_end_matches(".volume").to_string();
1420 steps.push(Step::StopService {
1427 unit: format!("{vol}-volume"),
1428 });
1429 }
1430 steps.push(Step::RemoveFile(entry.path()));
1431 }
1432 }
1433
1434 steps.push(Step::DaemonReload);
1436
1437 let mut seen_volumes = std::collections::BTreeSet::new();
1443 for svc in data::enumerate_all().unwrap_or_default() {
1444 for vol in svc.volumes {
1445 if seen_volumes.insert(vol.name.clone()) {
1446 steps.push(Step::RemoveVolume { name: vol.name });
1447 }
1448 }
1449 }
1450
1451 let data_root = service_data_root()?;
1457 if data_root.exists() {
1458 steps.push(Step::RemoveDir(data_root));
1459 }
1460
1461 Ok(ResetResult { steps })
1462}
1463
1464pub fn finalize_reset() -> Result<()> {
1466 let paths = ConfigPaths::resolve()?;
1467 if paths.config_dir.exists() {
1468 std::fs::remove_dir_all(&paths.config_dir).map_err(|source| Error::FileWrite {
1469 path: paths.config_dir,
1470 source,
1471 })?;
1472 }
1473 Ok(())
1474}
1475
1476pub fn status() -> config::status::RyraStatus {
1482 let paths = match ConfigPaths::resolve() {
1483 Ok(p) => p,
1484 Err(_) => return config::status::RyraStatus::NotInitialized,
1485 };
1486
1487 let has_quadlets = scan_managed_services()
1488 .map(|n| !n.is_empty())
1489 .unwrap_or(false);
1490
1491 let config = match config::load_config(&paths.config_file) {
1492 Ok(c) => c,
1493 Err(Error::ConfigNotFound(_)) if has_quadlets => config::schema::Config::default(),
1494 Err(Error::ConfigNotFound(_)) => return config::status::RyraStatus::NotInitialized,
1495 Err(e) => return config::status::RyraStatus::Error(e.to_string()),
1496 };
1497
1498 config::status::RyraStatus::Initialized(config::status::StatusInfo::from_config(
1499 paths.config_file,
1500 &config,
1501 ))
1502}
1503
1504pub fn is_service_installed(name: &str) -> bool {
1512 let has_quadlet = scan_managed_services()
1513 .map(|names| names.iter().any(|n| n == name))
1514 .unwrap_or(false);
1515 if !has_quadlet {
1516 return false;
1517 }
1518 metadata_path(name).map(|p| p.exists()).unwrap_or(false)
1519}
1520
1521pub fn scan_managed_services() -> Result<Vec<String>> {
1534 let dir = match quadlet_dir() {
1535 Ok(d) => d,
1536 Err(_) => return Ok(Vec::new()),
1537 };
1538 let entries = match std::fs::read_dir(&dir) {
1539 Ok(e) => e,
1540 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
1541 Err(source) => return Err(Error::FileRead { path: dir, source }),
1542 };
1543 let mut names: Vec<String> = Vec::new();
1544 for entry in entries.flatten() {
1545 let path = entry.path();
1546 if path.extension().and_then(|e| e.to_str()) != Some("container") {
1547 continue;
1548 }
1549 let Ok(content) = std::fs::read_to_string(&path) else {
1550 continue;
1551 };
1552 for line in content.lines().take(16) {
1553 if let Some(rest) = line.trim().strip_prefix("# Service-Source: registry/")
1554 && !rest.is_empty()
1555 && !names.iter().any(|n| n == rest)
1556 {
1557 names.push(rest.to_string());
1558 break;
1559 }
1560 }
1561 }
1562 names.sort();
1563 Ok(names)
1564}
1565
1566fn build_installed_from_metadata(service_name: &str) -> Option<InstalledService> {
1572 let meta = load_metadata(service_name).ok().flatten()?;
1573
1574 let exposure = match meta.url.as_deref() {
1576 None => Exposure::Loopback,
1577 Some(u) => Exposure::from_url(u),
1578 };
1579
1580 let auth_kind = meta.auth.clone();
1581
1582 let ports = service_home(service_name)
1588 .ok()
1589 .and_then(|home| std::fs::read_to_string(home.join(".env")).ok())
1590 .map(|env| {
1591 env.lines()
1592 .filter_map(|l| {
1593 let l = l.trim();
1594 if l.is_empty() || l.starts_with('#') {
1595 return None;
1596 }
1597 let (key, val) = l.split_once('=')?;
1598 let name = key.strip_prefix("SERVICE_PORT_")?.to_lowercase();
1599 let port = val
1600 .trim_matches(|c: char| c == '"' || c == '\'')
1601 .parse::<u16>()
1602 .ok()?;
1603 Some((name, port))
1604 })
1605 .collect::<std::collections::BTreeMap<String, u16>>()
1606 })
1607 .unwrap_or_default();
1608
1609 Some(InstalledService {
1610 name: service_name.to_string(),
1611 version: "0.1.0".to_string(),
1612 repo: meta.registry,
1613 ports,
1614 auth_kind,
1615 exposure,
1616 provides: meta.provides,
1617 installed: true,
1618 })
1619}
1620
1621pub fn list_installed() -> Result<Vec<InstalledService>> {
1628 let names = scan_managed_services().unwrap_or_default();
1629 let out: Vec<InstalledService> = names
1630 .iter()
1631 .filter_map(|n| build_installed_from_metadata(n))
1632 .collect();
1633 Ok(out)
1634}
1635
1636pub fn search_services(repo_dir: &Path, query: Option<&str>) -> Result<Vec<SearchResult>> {
1638 let available = registry::list_available(repo_dir)?;
1639
1640 let results = available
1641 .into_iter()
1642 .filter(|reg_svc| match query {
1643 None => true,
1644 Some(q) => {
1645 let q = q.to_lowercase();
1646 reg_svc.def.service.name.to_lowercase().contains(&q)
1647 || reg_svc.def.service.description.to_lowercase().contains(&q)
1648 }
1649 })
1650 .map(|reg_svc| {
1651 let name = ®_svc.def.service.name;
1652 let installed = is_service_installed(name);
1653 let mut supports = Vec::new();
1654 for kind in ®_svc.def.integrations.auth {
1655 supports.push(kind.to_string());
1656 }
1657 if reg_svc.def.integrations.smtp {
1658 supports.push("smtp".to_string());
1659 }
1660 SearchResult {
1661 name: name.clone(),
1662 description: reg_svc.def.service.description,
1663 installed,
1664 supports,
1665 }
1666 })
1667 .collect();
1668
1669 Ok(results)
1670}
1671
1672pub struct SearchResult {
1673 pub name: String,
1674 pub description: String,
1675 pub installed: bool,
1676 pub supports: Vec<String>,
1678}
1679
1680pub async fn service_tests(service_name: &str) -> Result<ServiceTestInfo> {
1682 let installed = build_installed_from_metadata(service_name)
1683 .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
1684
1685 let service_ref = service_ref_from_installed(&installed);
1686 let repo_dir = resolve_registry_dir(&service_ref).await?;
1687
1688 let test_toml_path = repo_dir.join(service_name).join("test.toml");
1689 let env_file = service_home(service_name)?.join(".env");
1690
1691 if !test_toml_path.exists() {
1692 return Ok(ServiceTestInfo {
1693 service_name: service_name.to_string(),
1694 registry_name: service_ref.registry_name().to_string(),
1695 tests: vec![],
1696 env_file,
1697 });
1698 }
1699
1700 let content = std::fs::read_to_string(&test_toml_path).map_err(|source| Error::FileRead {
1701 path: test_toml_path.clone(),
1702 source,
1703 })?;
1704
1705 #[derive(serde::Deserialize)]
1706 struct TestFile {
1707 #[serde(default)]
1708 tests: Vec<registry::test_def::TestDef>,
1709 }
1710
1711 let parsed: TestFile = toml::from_str(&content).map_err(|source| Error::TomlParse {
1712 path: test_toml_path,
1713 source,
1714 })?;
1715
1716 Ok(ServiceTestInfo {
1717 service_name: service_name.to_string(),
1718 registry_name: service_ref.registry_name().to_string(),
1719 tests: parsed.tests,
1720 env_file,
1721 })
1722}
1723
1724pub struct ServiceTestInfo {
1725 pub service_name: String,
1726 pub registry_name: String,
1727 pub tests: Vec<registry::test_def::TestDef>,
1728 pub env_file: PathBuf,
1729}
1730
1731pub fn service_info(repo_dir: &Path, service_name: &str) -> Result<ServiceDetail> {
1733 let reg_service = registry::find_service(repo_dir, service_name)?;
1734 let def = ®_service.def;
1735
1736 Ok(ServiceDetail {
1737 name: def.service.name.clone(),
1738 description: def.service.description.clone(),
1739 url: def.service.url.clone(),
1740 ports: def
1741 .ports
1742 .iter()
1743 .map(|p| (p.container_port, p.protocol.clone(), p.name.clone()))
1744 .collect(),
1745 env_vars: def
1746 .env
1747 .iter()
1748 .map(|e| (e.name.clone(), e.prompt.clone()))
1749 .collect(),
1750 })
1751}
1752
1753pub struct ServiceDetail {
1754 pub name: String,
1755 pub description: String,
1756 pub url: Option<String>,
1757 pub ports: Vec<(u16, registry::service_def::PortProtocol, String)>,
1758 pub env_vars: Vec<(String, Option<String>)>,
1759}
1760
1761#[cfg(test)]
1762mod tests {
1763 use super::*;
1764
1765 #[test]
1766 fn static_template_filter_excludes_secrets_and_credentials() {
1767 assert!(is_static_template("3306"));
1769 assert!(is_static_template("mariadb"));
1770 assert!(is_static_template("{{service.port}}"));
1772 assert!(is_static_template("{{service.url}}"));
1773 assert!(is_static_template("{{auth.url}}"));
1774 assert!(is_static_template("{{auth.issuer}}"));
1775 assert!(is_static_template("{{auth.provider}}"));
1776 assert!(is_static_template("{{auth.internal_url}}"));
1777 assert!(is_static_template("{{smtp.host}}"));
1778 assert!(is_static_template("{{smtp.port}}"));
1779 assert!(is_static_template("{{smtp.from}}"));
1780 assert!(is_static_template("{{service.url}}/oauth/callback"));
1782
1783 assert!(!is_static_template("{{secret.admin_password}}"));
1785 assert!(!is_static_template("{{secret.jwt_key}}"));
1786 assert!(!is_static_template("{{auth.client_id}}"));
1788 assert!(!is_static_template("{{auth.client_secret}}"));
1789 assert!(!is_static_template("{{smtp.username}}"));
1791 assert!(!is_static_template("{{smtp.password}}"));
1792 assert!(!is_static_template(
1794 "redis://:{{secret.redis_pw}}@host:6379"
1795 ));
1796 }
1797
1798 #[test]
1799 fn tailscale_url_matches() {
1800 assert!(is_tailscale_url("http://debian.cobbler-tuna.ts.net"));
1801 assert!(is_tailscale_url("http://debian.cobbler-tuna.ts.net:10001/"));
1802 assert!(is_tailscale_url("https://foo.example-net.ts.net"));
1803 assert!(is_tailscale_url("http://HOST.COBBLER-TUNA.TS.NET"));
1804 }
1805
1806 #[test]
1807 fn tailscale_url_rejects() {
1808 assert!(!is_tailscale_url("https://nextcloud.internal:8443"));
1809 assert!(!is_tailscale_url("https://example.com"));
1810 assert!(!is_tailscale_url("http://127.0.0.1:10001"));
1811 assert!(!is_tailscale_url("https://ts.net"));
1813 assert!(!is_tailscale_url("https://evil-ts.net.example.com"));
1814 assert!(!is_tailscale_url("not a url"));
1815 }
1816
1817 #[test]
1818 fn public_url_accepts_public_domains() {
1819 assert!(is_public_url("https://seafile.ryra.no"));
1820 assert!(is_public_url("https://example.com"));
1821 assert!(is_public_url("https://docs.ryra.no:8443"));
1822 }
1823
1824 #[test]
1825 fn public_url_rejects_lan_and_tailnet() {
1826 assert!(!is_public_url("https://nextcloud.internal:8443"));
1827 assert!(!is_public_url("https://service.localhost"));
1828 assert!(!is_public_url("https://something.local"));
1829 assert!(!is_public_url("https://localhost:8080"));
1830 assert!(!is_public_url("https://debian.cobbler-tuna.ts.net"));
1831 assert!(!is_public_url("http://127.0.0.1:10001"));
1832 assert!(!is_public_url("http://192.168.1.10"));
1833 assert!(!is_public_url("http://[::1]"));
1834 assert!(!is_public_url("not a url"));
1835 }
1836
1837 #[test]
1842 fn networks_empty_when_no_auth() {
1843 let nets = resolve_extra_networks("whoami", false, false, false, false, false, false);
1844 assert!(nets.is_empty());
1845 }
1846
1847 #[test]
1848 fn networks_empty_when_auth_but_no_authelia() {
1849 let nets = resolve_extra_networks("forgejo", true, false, false, false, false, false);
1850 assert!(nets.is_empty());
1851 }
1852
1853 #[test]
1854 fn networks_authelia_when_auth_enabled() {
1855 let nets = resolve_extra_networks("forgejo", true, true, false, false, false, false);
1856 assert_eq!(nets, vec!["authelia"]);
1857 }
1858
1859 #[test]
1860 fn networks_auth_with_caddy_includes_both() {
1861 let nets = resolve_extra_networks("forgejo", true, true, true, false, false, false);
1862 assert!(nets.contains(&"authelia".to_string()));
1863 assert!(nets.contains(&"caddy".to_string()));
1864 }
1865
1866 #[test]
1867 fn networks_authelia_excluded_for_authelia_itself() {
1868 let nets = resolve_extra_networks("authelia", true, true, false, false, false, false);
1869 assert!(nets.is_empty());
1870 }
1871
1872 #[test]
1873 fn networks_smtp_joins_inbucket_without_caddy() {
1874 let nets = resolve_extra_networks("forgejo", false, false, false, true, false, true);
1876 assert_eq!(nets, vec!["inbucket"]);
1877 }
1878
1879 #[test]
1880 fn networks_smtp_skips_inbucket_when_it_is_self() {
1881 let nets = resolve_extra_networks("inbucket", false, false, false, true, false, true);
1882 assert!(!nets.contains(&"inbucket".to_string()));
1883 }
1884
1885 #[test]
1886 fn networks_smtp_skips_inbucket_when_not_installed() {
1887 let nets = resolve_extra_networks("forgejo", false, false, false, false, false, true);
1888 assert!(!nets.contains(&"inbucket".to_string()));
1889 }
1890
1891 #[test]
1892 fn quadlet_belongs_to_exact_match() {
1893 let all = &["foo", "foo-bar"];
1894 assert!(quadlet_belongs_to("foo.container", "foo", all));
1895 assert!(quadlet_belongs_to("foo.network", "foo", all));
1896 }
1897
1898 #[test]
1899 fn quadlet_belongs_to_sidecar() {
1900 let all = &["foo"];
1902 assert!(quadlet_belongs_to("foo-db.volume", "foo", all));
1903 }
1904
1905 #[test]
1906 fn quadlet_belongs_to_rejects_prefix_collision() {
1907 let all = &["foo", "foo-bar"];
1908 assert!(!quadlet_belongs_to("foo-bar.container", "foo", all));
1909 assert!(!quadlet_belongs_to("foo-bar-db.volume", "foo", all));
1910 }
1911
1912 #[test]
1913 fn quadlet_belongs_to_hyphenated_service() {
1914 let all = &["foo", "foo-bar"];
1915 assert!(quadlet_belongs_to("foo-bar.container", "foo-bar", all));
1916 assert!(quadlet_belongs_to("foo-bar-db.volume", "foo-bar", all));
1917 assert!(!quadlet_belongs_to("foo.container", "foo-bar", all));
1918 }
1919}