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::{REGISTRY_BUNDLED, metadata_path, quadlet_dir, service_data_root, service_home};
41pub use plan::{AddResult, RemoveResult, ResetResult, Step, TrackedEnv, Warning};
42pub use upgrade::{
43 BackupSnapshot, DEFAULT_BACKUP_KEEP, DiffEntry, DiffKind, DiffResult, EnvAddition,
44 RevertResult, UpgradeResult, diff_service, list_backups, prune_backups, revert_service,
45 upgrade_service,
46};
47pub use well_known::WellKnownService;
48
49pub(crate) use paths::home_dir;
50pub(crate) use well_known::caddy_https_port;
51
52pub async fn resolve_registry_dir(service_ref: ®istry::resolve::ServiceRef) -> Result<PathBuf> {
54 let paths = ConfigPaths::resolve()?;
55 paths.ensure_cache_dir()?;
56 let config = config::load_or_default(&paths.config_file)?;
57 registry::resolve::resolve_registry_dir(service_ref, &config, &paths.cache_dir).await
58}
59
60pub fn service_ref_from_installed(installed: &InstalledService) -> registry::resolve::ServiceRef {
62 if installed.repo.is_empty() || installed.repo == REGISTRY_BUNDLED {
63 registry::resolve::ServiceRef::Bundled(installed.name.clone())
64 } else {
65 registry::resolve::ServiceRef::Custom {
66 registry: installed.repo.clone(),
67 service: installed.name.clone(),
68 }
69 }
70}
71
72fn retroactive_network_joins(
81 new_service: &str,
82 quadlet_path: &std::path::Path,
83 _repo_dir: Option<&std::path::Path>,
84) -> Vec<Step> {
85 let mut steps = Vec::new();
86 let new_cap = if service_provides(new_service, Capability::ReverseProxy) {
91 Capability::ReverseProxy
92 } else if service_provides(new_service, Capability::SmtpRelay) {
93 Capability::SmtpRelay
94 } else {
95 return steps;
96 };
97
98 let installed = list_installed().unwrap_or_default();
99 for svc in &installed {
100 if !svc.provides.is_empty() {
103 continue;
104 }
105 let (network_name, should_join) = match new_cap {
106 Capability::ReverseProxy => {
107 let wants_proxy = matches!(
111 svc.exposure,
112 Exposure::Internal { .. } | Exposure::Public { .. }
113 );
114 (new_service.to_string(), wants_proxy)
115 }
116 Capability::SmtpRelay => {
117 (
120 new_service.to_string(),
121 service_uses_smtp_relay(&svc.name, new_service),
122 )
123 }
124 Capability::OidcProvider | Capability::ForwardAuthProvider => {
128 continue;
129 }
130 };
131 if !should_join {
132 continue;
133 }
134 let installed_names_owned: Vec<String> = installed.iter().map(|s| s.name.clone()).collect();
141 let all_service_names: Vec<&str> =
142 installed_names_owned.iter().map(|s| s.as_str()).collect();
143 let marker = format!("Network={network_name}.network");
144 let mut units_to_restart: Vec<String> = Vec::new();
145 let Ok(entries) = std::fs::read_dir(quadlet_path) else {
146 continue;
147 };
148 for entry in entries.flatten() {
149 let path = entry.path();
150 let name = match path.file_name().and_then(|n| n.to_str()) {
151 Some(n) if n.ends_with(".container") => n.to_string(),
152 _ => continue,
153 };
154 if !quadlet_belongs_to(&name, &svc.name, &all_service_names) {
155 continue;
156 }
157 let content = match std::fs::read_to_string(&path) {
158 Ok(c) => c,
159 Err(_) => continue,
160 };
161 if content.contains(&marker) {
162 continue;
163 }
164 let updated =
165 generate::bundle::inject_networks(&content, std::slice::from_ref(&network_name));
166 steps.push(Step::WriteFile(GeneratedFile {
167 path,
168 content: updated,
169 }));
170 let unit = name.trim_end_matches(".container").to_string();
173 units_to_restart.push(unit);
174 }
175 if !units_to_restart.is_empty() {
176 steps.push(Step::DaemonReload);
177 for unit in units_to_restart {
178 steps.push(Step::RestartService { unit });
179 }
180 }
181 }
182 steps
183}
184
185fn service_uses_smtp_relay(service_name: &str, relay_host: &str) -> bool {
191 let env_path = match service_home(service_name) {
192 Ok(h) => h.join(".env"),
193 Err(_) => return false,
194 };
195 let content = match std::fs::read_to_string(&env_path) {
196 Ok(c) => c,
197 Err(_) => return false,
198 };
199 let with_port = format!("{relay_host}:");
200 content.lines().any(|line| {
201 let Some((_, value)) = line.split_once('=') else {
202 return false;
203 };
204 let v = value.trim();
205 v == relay_host || v.starts_with(&with_port)
206 })
207}
208
209#[allow(clippy::too_many_arguments)]
221fn resolve_extra_networks(
222 service_name: &str,
223 enable_auth: bool,
224 authelia_installed: bool,
225 caddy_installed: bool,
226 inbucket_installed: bool,
227 has_url: bool,
228 has_smtp: bool,
229) -> Vec<String> {
230 let mut networks = Vec::new();
231 if enable_auth && authelia_installed && !WellKnownService::Authelia.matches(service_name) {
232 networks.push(WellKnownService::Authelia.to_string());
233 }
234 let joins_inbucket =
237 has_smtp && inbucket_installed && !WellKnownService::Inbucket.matches(service_name);
238 if joins_inbucket {
239 networks.push(WellKnownService::Inbucket.to_string());
240 }
241 let joins_caddy = (has_url || enable_auth || WellKnownService::Inbucket.matches(service_name))
242 && caddy_installed
243 && !WellKnownService::Caddy.matches(service_name);
244 if joins_caddy && !networks.contains(&WellKnownService::Caddy.to_string()) {
245 networks.push(WellKnownService::Caddy.to_string());
246 }
247 networks
248}
249
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257pub enum PlanMode {
258 Add,
262 Upgrade,
266}
267
268#[allow(clippy::too_many_arguments)]
274pub fn add_service(
275 service_name: &str,
276 exposure: &Exposure,
277 auth_kind: Option<registry::service_def::AuthKind>,
278 enable_auth: bool,
279 enable_smtp: bool,
280 enable_backup: bool,
281 env_overrides: &BTreeMap<String, String>,
282 enabled_groups: &std::collections::BTreeSet<String>,
283 registry_name: &str,
284 repo_dir: &Path,
285 pre_built_ctx: Option<BTreeMap<String, String>>,
286 port_in_use: &dyn Fn(u16) -> bool,
287 acme_mode: Option<&caddy::AcmeMode>,
288 mode: PlanMode,
289 port_overrides: &BTreeMap<String, u16>,
295) -> Result<AddResult> {
296 let url: Option<&str> = exposure.url();
303 let tailscale_enabled: bool = exposure.is_tailscale();
304 let paths = ConfigPaths::resolve()?;
305 let config = config::load_or_default(&paths.config_file)?;
306
307 if mode == PlanMode::Add {
313 if is_service_installed(service_name) {
314 return Err(Error::ServiceAlreadyInstalled(service_name.to_string()));
315 }
316
317 if data::enumerate_service(service_name)?.is_some() {
325 return Err(Error::ServiceIncomplete(service_name.to_string()));
326 }
327 }
328
329 let reg_service = registry::find_service(repo_dir, service_name)?;
330
331 if let Some(msg) = reg_service.def.check_architecture() {
333 return Err(Error::UnsupportedArchitecture(msg));
334 }
335
336 let missing_requires: Vec<&str> = reg_service
338 .def
339 .requires
340 .iter()
341 .filter(|r| !is_service_installed(&r.service))
342 .map(|r| r.service.as_str())
343 .collect();
344 if !missing_requires.is_empty() {
345 return Err(Error::MissingRequiredServices {
346 service: service_name.to_string(),
347 missing: missing_requires.iter().map(|s| s.to_string()).collect(),
348 });
349 }
350
351 if auth_kind.is_some() && config.auth.is_none() {
353 return Err(Error::AuthNotConfigured);
354 }
355
356 if enable_auth
360 && reg_service.def.integrations.auth.is_empty()
361 && !capability::def_provides(®_service.def, Capability::OidcProvider)
362 {
363 return Err(Error::NoOidcSupport(service_name.to_string()));
364 }
365
366 if enable_backup && !reg_service.def.integrations.backup {
370 return Err(Error::BackupNotSupported(service_name.to_string()));
371 }
372
373 for g in enabled_groups {
377 if !reg_service.def.env_groups.iter().any(|eg| &eg.name == g) {
378 let known: Vec<String> = reg_service
379 .def
380 .env_groups
381 .iter()
382 .map(|eg| eg.name.clone())
383 .collect();
384 let hint = if known.is_empty() {
385 " (service defines no env_groups)".to_string()
386 } else {
387 format!(" (known: {})", known.join(", "))
388 };
389 return Err(Error::UnknownEnvGroup {
390 service: service_name.to_string(),
391 group: g.clone(),
392 hint,
393 });
394 }
395 }
396
397 let mut port_warnings: Vec<Warning> = Vec::new();
403 let mut claimed: std::collections::HashSet<u16> = reg_service
404 .def
405 .ports
406 .iter()
407 .filter_map(|p| p.host_port)
408 .collect();
409 let mut resolved_ports: Vec<(String, u16)> = Vec::with_capacity(reg_service.def.ports.len());
410 for p in ®_service.def.ports {
411 let host = if let Some(pinned) = port_overrides.get(&p.name) {
412 *pinned
417 } else if let Some(hp) = p.host_port {
418 hp
419 } else {
420 let privileged = p.container_port < 1024;
421 let claimed_in_service = claimed.contains(&p.container_port);
422 let in_use = port_in_use(p.container_port);
423 if privileged || claimed_in_service || in_use {
424 let allocated = system::port::allocate_port_excluding(&claimed, port_in_use)?;
425 let reason = if privileged {
426 "port is privileged (requires root)".to_string()
427 } else if claimed_in_service {
428 format!(
429 "port {} is already claimed by another port in this service",
430 p.container_port
431 )
432 } else {
433 format!("port {} is already in use", p.container_port)
434 };
435 port_warnings.push(Warning::PortReassigned {
436 service_name: service_name.to_string(),
437 port_name: p.name.clone(),
438 original_port: p.container_port,
439 assigned_port: allocated,
440 reason,
441 });
442 allocated
443 } else {
444 p.container_port
445 }
446 };
447 claimed.insert(host);
448 resolved_ports.push((p.name.clone(), host));
449 }
450
451 if WellKnownService::Caddy.matches(service_name)
458 && system::sysctl::rootless_can_bind_low_ports()
459 {
460 for (name, port) in resolved_ports.iter_mut() {
461 match name.as_str() {
462 "http" if *port == 8080 => *port = 80,
463 "https" if *port == 8443 => *port = 443,
464 _ => {}
465 }
466 }
467 }
468
469 let host_port = resolved_ports
472 .iter()
473 .find(|(name, _)| name.eq_ignore_ascii_case("http"))
474 .or_else(|| resolved_ports.first())
475 .map(|(_, p)| *p);
476
477 for (_, port) in &resolved_ports {
481 if port_in_use(*port) {
482 return Err(Error::PortConflict { port: *port });
483 }
484 }
485
486 let home_dir = service_home(service_name)?;
487 let quadlet_path = quadlet_dir()?;
488
489 let installed_now = list_installed().unwrap_or_default();
493 let authelia_installed =
494 find_installed_provider(&installed_now, Capability::OidcProvider).is_some();
495 let caddy_installed =
496 find_installed_provider(&installed_now, Capability::ReverseProxy).is_some();
497 let inbucket_installed =
498 find_installed_provider(&installed_now, Capability::SmtpRelay).is_some();
499
500 let auth_bridge = auth_bridge::build(&auth_bridge::AuthBridgeParams {
504 service_name,
505 service_provides: ®_service.def.capabilities.provides,
506 enable_auth,
507 config: &config,
508 installed: &installed_now,
509 service_data: &home_dir,
510 })?;
511
512 let (extra_volumes, extra_env, extra_exec_start_pre, auth_bridge_steps) = match auth_bridge {
513 Some(b) => (b.volumes, b.env, b.exec_start_pre, b.steps),
514 None => (Vec::new(), BTreeMap::new(), Vec::new(), Vec::new()),
515 };
516
517 let has_smtp = enable_smtp
518 && reg_service.def.integrations.smtp
519 && !reg_service.def.mappings.smtp.is_empty()
520 && config.smtp.is_some();
521 let extra_networks = resolve_extra_networks(
522 service_name,
523 enable_auth,
524 authelia_installed,
525 caddy_installed,
526 inbucket_installed,
527 url.is_some(),
528 has_smtp,
529 );
530
531 let output = generate::generate_env(generate::GenerateEnvParams {
532 config: &config,
533 service_def: ®_service.def,
534 auth_kind: auth_kind.as_ref(),
535 host_port,
536 resolved_ports: &resolved_ports,
537 env_overrides,
538 url,
539 extra_env,
540 pre_built_ctx,
541 enable_smtp: has_smtp,
542 enabled_groups,
543 })?;
544
545 let podman_args: Vec<String> = Vec::new();
546
547 let port_vars: Vec<(String, String)> = resolved_ports
550 .iter()
551 .map(|(name, port)| {
552 (
553 format!("SERVICE_PORT_{}", name.to_uppercase()),
554 port.to_string(),
555 )
556 })
557 .collect();
558
559 let install_metadata = Metadata {
572 registry: registry_name.to_string(),
573 url: url.map(str::to_string),
574 auth: auth_kind.clone(),
575 provides: reg_service.def.capabilities.provides.clone(),
576 backup_enabled: enable_backup,
577 smtp_enabled: enable_smtp,
578 enabled_groups: enabled_groups.iter().cloned().collect(),
579 };
580
581 let bundle =
583 generate::bundle::process_quadlet_bundle(&generate::bundle::ProcessBundleParams {
584 service_dir: ®_service.service_dir,
585 service_name,
586 extra_networks: &extra_networks,
587 extra_volumes: &extra_volumes,
588 podman_args: &podman_args,
589 extra_exec_start_pre: &extra_exec_start_pre,
590 port_vars: &port_vars,
591 })?;
592
593 let mut warnings = Vec::new();
595
596 if let Some(ref reqs) = reg_service.def.requirements
597 && let Some(total) = system::memory::total_ram_mb()
598 {
599 if total < reqs.ram.min {
600 warnings.push(Warning::RamBelowMinimum {
601 service_name: service_name.to_string(),
602 min_mb: reqs.ram.min,
603 available_mb: total,
604 });
605 } else if let Some(rec) = reqs.ram.recommended
606 && total < rec
607 {
608 warnings.push(Warning::RamBelowRecommended {
609 service_name: service_name.to_string(),
610 recommended_mb: rec,
611 available_mb: total,
612 });
613 }
614 }
615 warnings.extend(port_warnings);
616
617 let mut steps = Vec::new();
619
620 steps.push(Step::CreateDir(home_dir.clone()));
622
623 let env_content = output.env_file.content.clone();
625
626 for image in &bundle.images {
628 steps.push(Step::PullImage {
629 image: image.clone(),
630 });
631 }
632
633 for file in bundle.quadlet_files {
637 let link = file
638 .path
639 .file_name()
640 .map(|n| quadlet_path.join(n))
641 .ok_or_else(|| {
642 Error::Bundle(format!("invalid quadlet path: {}", file.path.display()))
643 })?;
644 let target = file.path.clone();
645 steps.push(Step::WriteFile(file));
646 steps.push(Step::Symlink { link, target });
647 }
648
649 let metadata_content = toml::to_string_pretty(&install_metadata)?;
656 steps.push(Step::WriteFile(GeneratedFile {
657 path: metadata_path(service_name)?,
658 content: metadata_content,
659 }));
660
661 if mode == PlanMode::Add
669 && tailscale_enabled
670 && let Some(port) = host_port
671 {
672 let svc_name = exposure.tailscale_svc_name().ok_or_else(|| {
681 Error::InvalidServiceRef(format!(
682 "tailscale exposure for '{service_name}' has a malformed URL — \
683 expected `https://<service>-<host>.<tailnet>.ts.net/`"
684 ))
685 })?;
686 steps.push(Step::TailscaleSetup);
687 steps.push(Step::TailscaleEnable {
688 svc_name,
689 host_port: port,
690 });
691 }
692
693 for file in bundle.config_files {
695 steps.push(Step::WriteFile(file));
696 }
697
698 for (src, dst) in bundle.files {
702 steps.push(Step::CopyFile { src, dst });
703 }
704
705 steps.push(Step::WriteFile(output.env_file));
707
708 for dir in &bundle.bind_mount_dirs {
710 steps.push(Step::CreateDir(dir.clone()));
711 }
712
713 steps.extend(auth_bridge_steps);
717
718 if mode == PlanMode::Add
727 && let (
728 Some(registry::service_def::AuthKind::Oidc),
729 Some(config::schema::AuthCredentials::Authelia { .. }),
730 ) = (auth_kind.as_ref(), config.auth.as_ref())
731 {
732 steps.extend(authelia::register_oidc_client(
733 service_name,
734 ®_service.def,
735 url,
736 &output.ctx,
737 &config,
738 &quadlet_path,
739 )?);
740 }
741
742 if let Some(url) = url
748 && !WellKnownService::Caddy.matches(service_name)
749 && !exposure.is_tailscale()
750 {
751 if caddy_installed {
752 let parsed = url::Url::parse(url)
753 .map_err(|e| Error::Template(format!("invalid service URL '{url}': {e}")))?;
754 let domain = parsed.host_str().ok_or_else(|| {
755 Error::Template(format!(
756 "service URL '{url}' has no host — Caddy needs a hostname to route to"
757 ))
758 })?;
759 let container_port = reg_service
760 .def
761 .ports
762 .first()
763 .map(|p| p.container_port)
764 .unwrap_or(80);
765 let primary_quadlet = reg_service
766 .service_dir
767 .join("quadlets")
768 .join(format!("{service_name}.container"));
769 let target_host = caddy::primary_container_name(&primary_quadlet, service_name);
770 let block = caddy::render_site_block(&caddy::CaddySiteParams {
771 service_name: service_name.to_string(),
772 target_host,
773 domain: domain.to_string(),
774 container_port,
775 https_port: caddy_https_port(&config),
776 });
777 let caddyfile_path = caddy::caddyfile_path()?;
778 let existing =
779 std::fs::read_to_string(&caddyfile_path).map_err(|source| Error::FileRead {
780 path: caddyfile_path.clone(),
781 source,
782 })?;
783 let updated = caddy::add_route(&existing, service_name, &block);
784 steps.push(Step::WriteFile(GeneratedFile {
785 path: caddyfile_path,
786 content: updated,
787 }));
788 steps.push(Step::ReloadCaddy);
789 } else if let Some(primary) = host_port {
790 warnings.push(Warning::UrlWithoutReverseProxy {
795 service_name: service_name.to_string(),
796 url: url.to_string(),
797 host_port: primary,
798 });
799 }
800 }
801
802 if mode == PlanMode::Add {
812 steps.extend(retroactive_network_joins(
813 service_name,
814 &quadlet_path,
815 Some(repo_dir),
816 ));
817 }
818
819 if WellKnownService::Caddy.matches(service_name) {
826 let snippet_path = caddy::tls_snippet_path()?;
827 if !snippet_path.exists() {
828 let mode = acme_mode.cloned().unwrap_or(caddy::AcmeMode::Internal);
829 steps.push(Step::WriteFile(GeneratedFile {
830 path: snippet_path,
831 content: mode.snippet(),
832 }));
833 }
834 }
835
836 let manifest_path_for_svc = manifest::manifest_path(service_name)?;
845 let env_filename = std::ffi::OsStr::new(".env");
846 let mut manifest_entries: Vec<manifest::ManifestEntry> = Vec::new();
847 for step in &steps {
848 if let Step::WriteFile(file) = step {
849 if file.path == manifest_path_for_svc {
850 continue;
851 }
852 if file.path.file_name() == Some(env_filename) {
853 continue;
854 }
855 manifest_entries.push(manifest::ManifestEntry {
856 path: file.path.clone(),
857 sha256: manifest::hash_bytes(file.content.as_bytes()),
858 });
859 }
860 }
861 let tracked_envs = collect_static_envs(®_service.def, &output.ctx, enabled_groups)?;
869 let manifest_envs: Vec<manifest::EnvEntry> = tracked_envs
870 .iter()
871 .map(|t| manifest::EnvEntry {
872 key: t.key.clone(),
873 value: t.value.clone(),
874 })
875 .collect();
876 steps.push(Step::WriteFile(GeneratedFile {
877 path: manifest_path_for_svc,
878 content: manifest::format(&manifest_entries, &manifest_envs),
879 }));
880
881 steps.push(Step::DaemonReload);
883 steps.push(Step::StartService {
885 unit: service_name.to_string(),
886 });
887
888 let allocated_ports: Vec<(String, u16)> = resolved_ports.clone();
890
891 let mut generated_secrets: Vec<String> = reg_service
893 .def
894 .env
895 .iter()
896 .filter(|e| !env_overrides.contains_key(&e.name))
897 .flat_map(|e| generate::extract_secret_refs(&e.value))
898 .collect();
899 generated_secrets.sort();
901 generated_secrets.dedup();
902
903 Ok(AddResult {
904 steps,
905 warnings,
906 repo_url: registry_name.to_string(),
907 allocated_ports,
908 generated_secrets,
909 env_content,
910 url: url.map(|u| u.to_string()),
911 tracked_envs,
912 })
913}
914
915pub fn quadlet_belongs_to(filename: &str, service_name: &str, all_service_names: &[&str]) -> bool {
924 if !filename.starts_with(service_name) {
925 return false;
926 }
927 let rest = &filename[service_name.len()..];
928 if rest.starts_with('.') {
929 return true;
930 }
931 if !rest.starts_with('-') {
932 return false;
933 }
934 !all_service_names.iter().any(|&other| {
938 other.len() > service_name.len()
939 && other.starts_with(service_name)
940 && filename.starts_with(other)
941 && filename[other.len()..].starts_with(['.', '-'])
942 })
943}
944
945#[derive(Debug, Clone, Copy, PartialEq, Eq)]
947pub enum RemoveMode {
948 Preserve,
953 Purge,
955}
956
957pub fn remove_service(service_name: &str, mode: RemoveMode) -> Result<RemoveResult> {
959 let installed_owned = build_installed_from_metadata(service_name)
962 .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
963 let installed = &installed_owned;
964
965 let quadlet_path = quadlet_dir()?;
968 let mut steps = Vec::new();
969 let mut volume_names = Vec::new();
970 let mut has_named_volumes = false;
971 let name_pool = scan_managed_services().unwrap_or_default();
975 let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
976
977 if let Some(svc_name) = installed.exposure.tailscale_svc_name() {
989 steps.push(Step::TailscaleDisable { svc_name });
990 }
991
992 if quadlet_path.is_dir()
993 && let Ok(entries) = std::fs::read_dir(&quadlet_path)
994 {
995 for entry in entries.flatten() {
996 let file_name = entry.file_name();
997 let name = file_name.to_string_lossy();
998 if !quadlet_belongs_to(&name, service_name, &all_names) {
1001 continue;
1002 }
1003 if name.ends_with(".container") {
1005 let unit = name.trim_end_matches(".container").to_string();
1006 steps.push(Step::StopService { unit });
1007 }
1008 if name.ends_with(".volume") {
1009 has_named_volumes = true;
1010 if matches!(mode, RemoveMode::Purge) {
1011 let vol = name.trim_end_matches(".volume").to_string();
1012 volume_names.push(format!("systemd-{vol}"));
1014 }
1015 }
1016 steps.push(Step::RemoveFile(entry.path()));
1017 }
1018 }
1019
1020 let had_caddy_route = matches!(
1027 installed.exposure,
1028 Exposure::Internal { .. } | Exposure::Public { .. }
1029 );
1030 if !WellKnownService::Caddy.matches(service_name) && had_caddy_route {
1031 let caddyfile_path = caddy::caddyfile_path()?;
1032 if caddyfile_path.exists() {
1033 let existing =
1034 std::fs::read_to_string(&caddyfile_path).map_err(|source| Error::FileRead {
1035 path: caddyfile_path.clone(),
1036 source,
1037 })?;
1038 let updated = caddy::remove_route(&existing, service_name);
1039 if updated != existing {
1040 steps.push(Step::WriteFile(GeneratedFile {
1041 path: caddyfile_path,
1042 content: updated.clone(),
1043 }));
1044 if !updated.trim().is_empty() {
1047 steps.push(Step::ReloadCaddy);
1048 }
1049 }
1050 }
1051 }
1052
1053 if !WellKnownService::Authelia.matches(service_name)
1054 && matches!(
1055 installed.auth_kind,
1056 Some(registry::service_def::AuthKind::Oidc)
1057 )
1058 {
1059 steps.extend(authelia::unregister_oidc_client(service_name)?);
1060 }
1061
1062 steps.push(Step::DaemonReload);
1064
1065 match mode {
1066 RemoveMode::Purge => {
1067 for vol_name in volume_names {
1069 steps.push(Step::RemoveVolume { name: vol_name });
1070 }
1071 steps.push(Step::RemoveDir(service_home(service_name)?));
1073 }
1074 RemoveMode::Preserve => {
1075 let home = service_home(service_name)?;
1079 let (data, ephemeral) = crate::data::classify::classify_home_dir(&home)?;
1080 for path in ephemeral {
1081 match std::fs::metadata(&path) {
1082 Ok(m) if m.is_dir() => steps.push(Step::RemoveDir(path)),
1083 Ok(_) => steps.push(Step::RemoveFile(path)),
1084 Err(_) => steps.push(Step::RemoveFile(path)),
1088 }
1089 }
1090 if data.is_empty() && !has_named_volumes && home.exists() {
1098 steps.push(Step::RemoveDir(home));
1099 }
1100 }
1101 }
1102
1103 let url = installed.exposure.url().map(|s| s.to_string());
1104
1105 Ok(RemoveResult {
1106 steps,
1107 service_name: service_name.to_string(),
1108 url,
1109 })
1110}
1111
1112pub struct RecordPendingParams<'a> {
1114 pub service_name: &'a str,
1115 pub auth_kind: Option<registry::service_def::AuthKind>,
1116 pub registry_name: &'a str,
1117 pub allocated_ports: &'a [(String, u16)],
1118 pub repo_dir: &'a Path,
1119 pub exposure: &'a Exposure,
1126}
1127
1128pub fn record_pending(params: RecordPendingParams<'_>) -> Result<()> {
1135 let paths = ConfigPaths::resolve()?;
1136 paths.ensure_dirs()?;
1137 let mut config = config::load_or_default(&paths.config_file)?;
1138
1139 if WellKnownService::Authelia.matches(params.service_name) {
1144 config.auth = Some(authelia::auth_config(
1145 params.allocated_ports,
1146 params.exposure.url(),
1147 )?);
1148 config::save_config(&paths.config_file, &config)?;
1149 }
1150
1151 Ok(())
1152}
1153
1154pub fn finalize_remove(service_name: &str) -> Result<()> {
1161 let paths = ConfigPaths::resolve()?;
1162 let mut config = config::load_or_default(&paths.config_file)?;
1163
1164 if WellKnownService::Authelia.matches(service_name)
1165 && let Some(auth) = &config.auth
1166 && auth.provider_name() == "authelia"
1167 {
1168 config.auth = None;
1169 config::save_config(&paths.config_file, &config)?;
1170 }
1171
1172 Ok(())
1173}
1174
1175const SENSITIVE_TEMPLATE_REFS: &[&str] = &[
1194 "{{secret.",
1195 "{{auth.client_id",
1196 "{{auth.client_secret",
1197 "{{smtp.username",
1198 "{{smtp.password",
1199];
1200
1201fn is_static_template(value: &str) -> bool {
1202 !SENSITIVE_TEMPLATE_REFS.iter().any(|s| value.contains(s))
1203}
1204
1205fn collect_static_envs(
1221 service_def: ®istry::service_def::ServiceDef,
1222 ctx: &BTreeMap<String, String>,
1223 enabled_groups: &std::collections::BTreeSet<String>,
1224) -> Result<Vec<plan::TrackedEnv>> {
1225 use registry::service_def::EnvKind;
1226 let mut out: Vec<plan::TrackedEnv> = Vec::new();
1227 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
1228 let push = |name: &str,
1229 value_template: &str,
1230 kind: EnvKind,
1231 prompt: Option<String>,
1232 out: &mut Vec<plan::TrackedEnv>,
1233 seen: &mut std::collections::HashSet<String>|
1234 -> Result<()> {
1235 if !is_static_template(value_template) {
1236 return Ok(());
1237 }
1238 if !seen.insert(name.to_string()) {
1239 return Ok(());
1240 }
1241 let value = generate::template::render(value_template, ctx)?;
1242 out.push(plan::TrackedEnv {
1243 key: name.to_string(),
1244 value,
1245 kind,
1246 prompt,
1247 });
1248 Ok(())
1249 };
1250 for env in &service_def.env {
1251 push(
1252 &env.name,
1253 &env.value,
1254 env.kind.clone(),
1255 env.prompt.clone(),
1256 &mut out,
1257 &mut seen,
1258 )?;
1259 }
1260 for group in &service_def.env_groups {
1261 if !enabled_groups.contains(&group.name) {
1262 continue;
1263 }
1264 for env in &group.env {
1265 push(
1266 &env.name,
1267 &env.value,
1268 env.kind.clone(),
1269 env.prompt.clone(),
1270 &mut out,
1271 &mut seen,
1272 )?;
1273 }
1274 }
1275 if service_def.integrations.smtp && ctx.contains_key("smtp.host") {
1281 for (env_name, value_template) in &service_def.mappings.smtp {
1282 push(
1283 env_name,
1284 value_template,
1285 EnvKind::Default,
1286 None,
1287 &mut out,
1288 &mut seen,
1289 )?;
1290 }
1291 }
1292 if ctx.contains_key("auth.client_id") {
1293 for (env_name, value_template) in &service_def.mappings.auth {
1294 push(
1295 env_name,
1296 value_template,
1297 EnvKind::Default,
1298 None,
1299 &mut out,
1300 &mut seen,
1301 )?;
1302 }
1303 }
1304 Ok(out)
1305}
1306
1307pub fn orphan_purge_steps(svc: &data::ServiceData) -> Vec<Step> {
1308 let mut steps = Vec::new();
1309
1310 let mut had_quadlet = false;
1316 if let Ok(qdir) = quadlet_dir()
1317 && qdir.is_dir()
1318 && let Ok(entries) = std::fs::read_dir(&qdir)
1319 {
1320 let name_pool = scan_managed_services().unwrap_or_default();
1321 let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
1322 for entry in entries.flatten() {
1323 let file_name = entry.file_name();
1324 let name = file_name.to_string_lossy();
1325 if !quadlet_belongs_to(&name, &svc.service, &all_names) {
1326 continue;
1327 }
1328 if name.ends_with(".container") {
1332 let unit = name.trim_end_matches(".container").to_string();
1333 steps.push(Step::StopService { unit });
1334 } else if name.ends_with(".network") {
1335 let unit = format!("{}-network", name.trim_end_matches(".network"));
1336 steps.push(Step::StopService { unit });
1337 } else if name.ends_with(".volume") {
1338 let unit = format!("{}-volume", name.trim_end_matches(".volume"));
1339 steps.push(Step::StopService { unit });
1340 }
1341 steps.push(Step::RemoveFile(entry.path()));
1342 had_quadlet = true;
1343 }
1344 }
1345 if had_quadlet {
1346 steps.push(Step::DaemonReload);
1347 }
1348
1349 for path in &svc.data_paths {
1350 if path.is_dir() {
1351 steps.push(Step::RemoveDir(path.clone()));
1352 } else {
1353 steps.push(Step::RemoveFile(path.clone()));
1354 }
1355 }
1356 if svc.home_dir.exists() {
1357 steps.push(Step::RemoveDir(svc.home_dir.clone()));
1358 }
1359 for v in &svc.volumes {
1360 steps.push(Step::RemoveVolume {
1361 name: v.name.clone(),
1362 });
1363 }
1364 steps
1365}
1366
1367pub fn reset() -> Result<ResetResult> {
1369 let mut steps = Vec::new();
1370
1371 let managed_names = scan_managed_services().unwrap_or_default();
1376
1377 for svc in list_installed().unwrap_or_default() {
1384 if let Some(svc_name) = svc.exposure.tailscale_svc_name() {
1385 steps.push(Step::TailscaleDisable { svc_name });
1386 }
1387 }
1388
1389 let quadlet_path = quadlet_dir()?;
1391 let all_names: Vec<&str> = managed_names.iter().map(|s| s.as_str()).collect();
1392 if quadlet_path.is_dir()
1393 && let Ok(entries) = std::fs::read_dir(&quadlet_path)
1394 {
1395 for entry in entries.flatten() {
1396 let file_name = entry.file_name();
1397 let name = file_name.to_string_lossy();
1398 let is_ryra_file = managed_names
1402 .iter()
1403 .any(|svc| quadlet_belongs_to(&name, svc, &all_names));
1404 if !is_ryra_file {
1405 continue;
1406 }
1407 if name.ends_with(".container") {
1408 let unit = name.trim_end_matches(".container").to_string();
1409 steps.push(Step::StopService { unit });
1410 }
1411 if name.ends_with(".network") {
1412 let unit = format!("{}-network", name.trim_end_matches(".network"));
1413 steps.push(Step::StopService { unit });
1414 }
1415 if name.ends_with(".volume") {
1416 let vol = name.trim_end_matches(".volume").to_string();
1417 steps.push(Step::StopService {
1424 unit: format!("{vol}-volume"),
1425 });
1426 }
1427 steps.push(Step::RemoveFile(entry.path()));
1428 }
1429 }
1430
1431 steps.push(Step::DaemonReload);
1433
1434 let mut seen_volumes = std::collections::BTreeSet::new();
1440 for svc in data::enumerate_all().unwrap_or_default() {
1441 for vol in svc.volumes {
1442 if seen_volumes.insert(vol.name.clone()) {
1443 steps.push(Step::RemoveVolume { name: vol.name });
1444 }
1445 }
1446 }
1447
1448 let data_root = service_data_root()?;
1454 if data_root.exists() {
1455 steps.push(Step::RemoveDir(data_root));
1456 }
1457
1458 Ok(ResetResult { steps })
1459}
1460
1461pub fn finalize_reset() -> Result<()> {
1463 let paths = ConfigPaths::resolve()?;
1464 if paths.config_dir.exists() {
1465 std::fs::remove_dir_all(&paths.config_dir).map_err(|source| Error::FileWrite {
1466 path: paths.config_dir,
1467 source,
1468 })?;
1469 }
1470 Ok(())
1471}
1472
1473pub fn status() -> config::status::RyraStatus {
1479 let paths = match ConfigPaths::resolve() {
1480 Ok(p) => p,
1481 Err(_) => return config::status::RyraStatus::NotInitialized,
1482 };
1483
1484 let has_quadlets = scan_managed_services()
1485 .map(|n| !n.is_empty())
1486 .unwrap_or(false);
1487
1488 let config = match config::load_config(&paths.config_file) {
1489 Ok(c) => c,
1490 Err(Error::ConfigNotFound(_)) if has_quadlets => config::schema::Config::default(),
1491 Err(Error::ConfigNotFound(_)) => return config::status::RyraStatus::NotInitialized,
1492 Err(e) => return config::status::RyraStatus::Error(e.to_string()),
1493 };
1494
1495 config::status::RyraStatus::Initialized(config::status::StatusInfo::from_config(
1496 paths.config_file,
1497 &config,
1498 ))
1499}
1500
1501pub fn is_service_installed(name: &str) -> bool {
1509 let has_quadlet = scan_managed_services()
1510 .map(|names| names.iter().any(|n| n == name))
1511 .unwrap_or(false);
1512 if !has_quadlet {
1513 return false;
1514 }
1515 metadata_path(name).map(|p| p.exists()).unwrap_or(false)
1516}
1517
1518pub fn scan_managed_services() -> Result<Vec<String>> {
1531 let dir = match quadlet_dir() {
1532 Ok(d) => d,
1533 Err(_) => return Ok(Vec::new()),
1534 };
1535 let entries = match std::fs::read_dir(&dir) {
1536 Ok(e) => e,
1537 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
1538 Err(source) => return Err(Error::FileRead { path: dir, source }),
1539 };
1540 let mut names: Vec<String> = Vec::new();
1541 for entry in entries.flatten() {
1542 let path = entry.path();
1543 if path.extension().and_then(|e| e.to_str()) != Some("container") {
1544 continue;
1545 }
1546 let Ok(content) = std::fs::read_to_string(&path) else {
1547 continue;
1548 };
1549 for line in content.lines().take(16) {
1550 if let Some(rest) = line.trim().strip_prefix("# Service-Source: registry/")
1551 && !rest.is_empty()
1552 && !names.iter().any(|n| n == rest)
1553 {
1554 names.push(rest.to_string());
1555 break;
1556 }
1557 }
1558 }
1559 names.sort();
1560 Ok(names)
1561}
1562
1563fn build_installed_from_metadata(service_name: &str) -> Option<InstalledService> {
1569 let meta = load_metadata(service_name).ok().flatten()?;
1570
1571 let exposure = match meta.url.as_deref() {
1573 None => Exposure::Loopback,
1574 Some(u) => Exposure::from_url(u),
1575 };
1576
1577 let auth_kind = meta.auth.clone();
1578
1579 let ports = service_home(service_name)
1585 .ok()
1586 .and_then(|home| std::fs::read_to_string(home.join(".env")).ok())
1587 .map(|env| {
1588 env.lines()
1589 .filter_map(|l| {
1590 let l = l.trim();
1591 if l.is_empty() || l.starts_with('#') {
1592 return None;
1593 }
1594 let (key, val) = l.split_once('=')?;
1595 let name = key.strip_prefix("SERVICE_PORT_")?.to_lowercase();
1596 let port = val
1597 .trim_matches(|c: char| c == '"' || c == '\'')
1598 .parse::<u16>()
1599 .ok()?;
1600 Some((name, port))
1601 })
1602 .collect::<std::collections::BTreeMap<String, u16>>()
1603 })
1604 .unwrap_or_default();
1605
1606 Some(InstalledService {
1607 name: service_name.to_string(),
1608 version: "0.1.0".to_string(),
1609 repo: meta.registry,
1610 ports,
1611 auth_kind,
1612 exposure,
1613 provides: meta.provides,
1614 installed: true,
1615 })
1616}
1617
1618pub fn list_installed() -> Result<Vec<InstalledService>> {
1625 let names = scan_managed_services().unwrap_or_default();
1626 let out: Vec<InstalledService> = names
1627 .iter()
1628 .filter_map(|n| build_installed_from_metadata(n))
1629 .collect();
1630 Ok(out)
1631}
1632
1633pub fn search_services(repo_dir: &Path, query: Option<&str>) -> Result<Vec<SearchResult>> {
1635 let available = registry::list_available(repo_dir)?;
1636
1637 let results = available
1638 .into_iter()
1639 .filter(|reg_svc| match query {
1640 None => true,
1641 Some(q) => {
1642 let q = q.to_lowercase();
1643 reg_svc.def.service.name.to_lowercase().contains(&q)
1644 || reg_svc.def.service.description.to_lowercase().contains(&q)
1645 }
1646 })
1647 .map(|reg_svc| {
1648 let name = ®_svc.def.service.name;
1649 let installed = is_service_installed(name);
1650 let mut supports = Vec::new();
1651 for kind in ®_svc.def.integrations.auth {
1652 supports.push(kind.to_string());
1653 }
1654 if reg_svc.def.integrations.smtp {
1655 supports.push("smtp".to_string());
1656 }
1657 SearchResult {
1658 name: name.clone(),
1659 description: reg_svc.def.service.description,
1660 installed,
1661 supports,
1662 }
1663 })
1664 .collect();
1665
1666 Ok(results)
1667}
1668
1669pub struct SearchResult {
1670 pub name: String,
1671 pub description: String,
1672 pub installed: bool,
1673 pub supports: Vec<String>,
1675}
1676
1677pub async fn service_tests(service_name: &str) -> Result<ServiceTestInfo> {
1679 let installed = build_installed_from_metadata(service_name)
1680 .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
1681
1682 let service_ref = service_ref_from_installed(&installed);
1683 let repo_dir = resolve_registry_dir(&service_ref).await?;
1684
1685 let test_toml_path = repo_dir.join(service_name).join("test.toml");
1686 let env_file = service_home(service_name)?.join(".env");
1687
1688 if !test_toml_path.exists() {
1689 return Ok(ServiceTestInfo {
1690 service_name: service_name.to_string(),
1691 registry_name: service_ref.registry_name().to_string(),
1692 tests: vec![],
1693 env_file,
1694 });
1695 }
1696
1697 let content = std::fs::read_to_string(&test_toml_path).map_err(|source| Error::FileRead {
1698 path: test_toml_path.clone(),
1699 source,
1700 })?;
1701
1702 #[derive(serde::Deserialize)]
1703 struct TestFile {
1704 #[serde(default)]
1705 tests: Vec<registry::test_def::TestDef>,
1706 }
1707
1708 let parsed: TestFile = toml::from_str(&content).map_err(|source| Error::TomlParse {
1709 path: test_toml_path,
1710 source,
1711 })?;
1712
1713 Ok(ServiceTestInfo {
1714 service_name: service_name.to_string(),
1715 registry_name: service_ref.registry_name().to_string(),
1716 tests: parsed.tests,
1717 env_file,
1718 })
1719}
1720
1721pub struct ServiceTestInfo {
1722 pub service_name: String,
1723 pub registry_name: String,
1724 pub tests: Vec<registry::test_def::TestDef>,
1725 pub env_file: PathBuf,
1726}
1727
1728pub fn service_info(repo_dir: &Path, service_name: &str) -> Result<ServiceDetail> {
1730 let reg_service = registry::find_service(repo_dir, service_name)?;
1731 let def = ®_service.def;
1732
1733 Ok(ServiceDetail {
1734 name: def.service.name.clone(),
1735 description: def.service.description.clone(),
1736 url: def.service.url.clone(),
1737 ports: def
1738 .ports
1739 .iter()
1740 .map(|p| (p.container_port, p.protocol.clone(), p.name.clone()))
1741 .collect(),
1742 env_vars: def
1743 .env
1744 .iter()
1745 .map(|e| (e.name.clone(), e.prompt.clone()))
1746 .collect(),
1747 })
1748}
1749
1750pub struct ServiceDetail {
1751 pub name: String,
1752 pub description: String,
1753 pub url: Option<String>,
1754 pub ports: Vec<(u16, registry::service_def::PortProtocol, String)>,
1755 pub env_vars: Vec<(String, Option<String>)>,
1756}
1757
1758#[cfg(test)]
1759mod tests {
1760 use super::*;
1761
1762 #[test]
1763 fn static_template_filter_excludes_secrets_and_credentials() {
1764 assert!(is_static_template("3306"));
1766 assert!(is_static_template("mariadb"));
1767 assert!(is_static_template("{{service.port}}"));
1769 assert!(is_static_template("{{service.url}}"));
1770 assert!(is_static_template("{{auth.url}}"));
1771 assert!(is_static_template("{{auth.issuer}}"));
1772 assert!(is_static_template("{{auth.provider}}"));
1773 assert!(is_static_template("{{auth.internal_url}}"));
1774 assert!(is_static_template("{{smtp.host}}"));
1775 assert!(is_static_template("{{smtp.port}}"));
1776 assert!(is_static_template("{{smtp.from}}"));
1777 assert!(is_static_template("{{service.url}}/oauth/callback"));
1779
1780 assert!(!is_static_template("{{secret.admin_password}}"));
1782 assert!(!is_static_template("{{secret.jwt_key}}"));
1783 assert!(!is_static_template("{{auth.client_id}}"));
1785 assert!(!is_static_template("{{auth.client_secret}}"));
1786 assert!(!is_static_template("{{smtp.username}}"));
1788 assert!(!is_static_template("{{smtp.password}}"));
1789 assert!(!is_static_template(
1791 "redis://:{{secret.redis_pw}}@host:6379"
1792 ));
1793 }
1794
1795 #[test]
1796 fn tailscale_url_matches() {
1797 assert!(is_tailscale_url("http://debian.cobbler-tuna.ts.net"));
1798 assert!(is_tailscale_url("http://debian.cobbler-tuna.ts.net:10001/"));
1799 assert!(is_tailscale_url("https://foo.example-net.ts.net"));
1800 assert!(is_tailscale_url("http://HOST.COBBLER-TUNA.TS.NET"));
1801 }
1802
1803 #[test]
1804 fn tailscale_url_rejects() {
1805 assert!(!is_tailscale_url("https://nextcloud.internal:8443"));
1806 assert!(!is_tailscale_url("https://example.com"));
1807 assert!(!is_tailscale_url("http://127.0.0.1:10001"));
1808 assert!(!is_tailscale_url("https://ts.net"));
1810 assert!(!is_tailscale_url("https://evil-ts.net.example.com"));
1811 assert!(!is_tailscale_url("not a url"));
1812 }
1813
1814 #[test]
1815 fn public_url_accepts_public_domains() {
1816 assert!(is_public_url("https://seafile.ryra.no"));
1817 assert!(is_public_url("https://example.com"));
1818 assert!(is_public_url("https://docs.ryra.no:8443"));
1819 }
1820
1821 #[test]
1822 fn public_url_rejects_lan_and_tailnet() {
1823 assert!(!is_public_url("https://nextcloud.internal:8443"));
1824 assert!(!is_public_url("https://service.localhost"));
1825 assert!(!is_public_url("https://something.local"));
1826 assert!(!is_public_url("https://localhost:8080"));
1827 assert!(!is_public_url("https://debian.cobbler-tuna.ts.net"));
1828 assert!(!is_public_url("http://127.0.0.1:10001"));
1829 assert!(!is_public_url("http://192.168.1.10"));
1830 assert!(!is_public_url("http://[::1]"));
1831 assert!(!is_public_url("not a url"));
1832 }
1833
1834 #[test]
1839 fn networks_empty_when_no_auth() {
1840 let nets = resolve_extra_networks("whoami", false, false, false, false, false, false);
1841 assert!(nets.is_empty());
1842 }
1843
1844 #[test]
1845 fn networks_empty_when_auth_but_no_authelia() {
1846 let nets = resolve_extra_networks("forgejo", true, false, false, false, false, false);
1847 assert!(nets.is_empty());
1848 }
1849
1850 #[test]
1851 fn networks_authelia_when_auth_enabled() {
1852 let nets = resolve_extra_networks("forgejo", true, true, false, false, false, false);
1853 assert_eq!(nets, vec!["authelia"]);
1854 }
1855
1856 #[test]
1857 fn networks_auth_with_caddy_includes_both() {
1858 let nets = resolve_extra_networks("forgejo", true, true, true, false, false, false);
1859 assert!(nets.contains(&"authelia".to_string()));
1860 assert!(nets.contains(&"caddy".to_string()));
1861 }
1862
1863 #[test]
1864 fn networks_authelia_excluded_for_authelia_itself() {
1865 let nets = resolve_extra_networks("authelia", true, true, false, false, false, false);
1866 assert!(nets.is_empty());
1867 }
1868
1869 #[test]
1870 fn networks_smtp_joins_inbucket_without_caddy() {
1871 let nets = resolve_extra_networks("forgejo", false, false, false, true, false, true);
1873 assert_eq!(nets, vec!["inbucket"]);
1874 }
1875
1876 #[test]
1877 fn networks_smtp_skips_inbucket_when_it_is_self() {
1878 let nets = resolve_extra_networks("inbucket", false, false, false, true, false, true);
1879 assert!(!nets.contains(&"inbucket".to_string()));
1880 }
1881
1882 #[test]
1883 fn networks_smtp_skips_inbucket_when_not_installed() {
1884 let nets = resolve_extra_networks("forgejo", false, false, false, false, false, true);
1885 assert!(!nets.contains(&"inbucket".to_string()));
1886 }
1887
1888 #[test]
1889 fn quadlet_belongs_to_exact_match() {
1890 let all = &["foo", "foo-bar"];
1891 assert!(quadlet_belongs_to("foo.container", "foo", all));
1892 assert!(quadlet_belongs_to("foo.network", "foo", all));
1893 }
1894
1895 #[test]
1896 fn quadlet_belongs_to_sidecar() {
1897 let all = &["foo"];
1899 assert!(quadlet_belongs_to("foo-db.volume", "foo", all));
1900 }
1901
1902 #[test]
1903 fn quadlet_belongs_to_rejects_prefix_collision() {
1904 let all = &["foo", "foo-bar"];
1905 assert!(!quadlet_belongs_to("foo-bar.container", "foo", all));
1906 assert!(!quadlet_belongs_to("foo-bar-db.volume", "foo", all));
1907 }
1908
1909 #[test]
1910 fn quadlet_belongs_to_hyphenated_service() {
1911 let all = &["foo", "foo-bar"];
1912 assert!(quadlet_belongs_to("foo-bar.container", "foo-bar", all));
1913 assert!(quadlet_belongs_to("foo-bar-db.volume", "foo-bar", all));
1914 assert!(!quadlet_belongs_to("foo.container", "foo-bar", all));
1915 }
1916}