1pub mod auth_bridge;
2pub mod authelia;
3pub mod backup;
4pub mod caddy;
5pub mod capability;
6pub mod config;
7pub mod data;
8pub mod error;
9pub mod exposure;
10pub mod generate;
11pub mod manifest;
12pub mod metadata;
13pub mod paths;
14pub mod plan;
15pub mod registry;
16pub mod system;
17pub mod upgrade;
18pub mod well_known;
19
20use std::collections::BTreeMap;
21use std::path::{Path, PathBuf};
22
23use config::ConfigPaths;
24use config::schema::InstalledService;
25use error::{Error, Result};
26
27pub use capability::{
28 Capability, any_installed_provider, find_installed_provider, installed_provides,
29 service_provides,
30};
31pub use exposure::{Exposure, is_public_url, is_tailscale_url};
32pub use generate::GeneratedFile;
33pub use manifest::{ManifestEntry, manifest_path};
34pub use metadata::{Metadata, load_metadata};
35pub use paths::{REGISTRY_BUNDLED, metadata_path, quadlet_dir, service_data_root, service_home};
36pub use plan::{AddResult, RemoveResult, ResetResult, Step, TrackedEnv, Warning};
37pub use upgrade::{
38 BackupSnapshot, DEFAULT_BACKUP_KEEP, DiffEntry, DiffKind, DiffResult, EnvAddition,
39 RevertResult, UpgradeResult, diff_service, list_backups, prune_backups, revert_service,
40 upgrade_service,
41};
42pub use well_known::WellKnownService;
43
44pub(crate) use paths::home_dir;
45pub(crate) use well_known::caddy_https_port;
46
47pub async fn resolve_registry_dir(service_ref: ®istry::resolve::ServiceRef) -> Result<PathBuf> {
49 let paths = ConfigPaths::resolve()?;
50 paths.ensure_cache_dir()?;
51 let config = config::load_or_default(&paths.config_file)?;
52 registry::resolve::resolve_registry_dir(service_ref, &config, &paths.cache_dir).await
53}
54
55pub fn service_ref_from_installed(installed: &InstalledService) -> registry::resolve::ServiceRef {
57 if installed.repo.is_empty() || installed.repo == REGISTRY_BUNDLED {
58 registry::resolve::ServiceRef::Bundled(installed.name.clone())
59 } else {
60 registry::resolve::ServiceRef::Custom {
61 registry: installed.repo.clone(),
62 service: installed.name.clone(),
63 }
64 }
65}
66
67fn retroactive_network_joins(
76 new_service: &str,
77 quadlet_path: &std::path::Path,
78 _repo_dir: Option<&std::path::Path>,
79) -> Vec<Step> {
80 let mut steps = Vec::new();
81 let new_cap = if service_provides(new_service, Capability::ReverseProxy) {
86 Capability::ReverseProxy
87 } else if service_provides(new_service, Capability::SmtpRelay) {
88 Capability::SmtpRelay
89 } else {
90 return steps;
91 };
92
93 let installed = list_installed().unwrap_or_default();
94 for svc in &installed {
95 if !svc.provides.is_empty() {
98 continue;
99 }
100 let (network_name, should_join) = match new_cap {
101 Capability::ReverseProxy => {
102 let wants_proxy = matches!(
106 svc.exposure,
107 Exposure::Internal { .. } | Exposure::Public { .. }
108 );
109 (new_service.to_string(), wants_proxy)
110 }
111 Capability::SmtpRelay => {
112 (
115 new_service.to_string(),
116 service_uses_smtp_relay(&svc.name, new_service),
117 )
118 }
119 Capability::OidcProvider | Capability::ForwardAuthProvider => {
123 continue;
124 }
125 };
126 if !should_join {
127 continue;
128 }
129 let installed_names_owned: Vec<String> = installed.iter().map(|s| s.name.clone()).collect();
136 let all_service_names: Vec<&str> =
137 installed_names_owned.iter().map(|s| s.as_str()).collect();
138 let marker = format!("Network={network_name}.network");
139 let mut units_to_restart: Vec<String> = Vec::new();
140 let Ok(entries) = std::fs::read_dir(quadlet_path) else {
141 continue;
142 };
143 for entry in entries.flatten() {
144 let path = entry.path();
145 let name = match path.file_name().and_then(|n| n.to_str()) {
146 Some(n) if n.ends_with(".container") => n.to_string(),
147 _ => continue,
148 };
149 if !quadlet_belongs_to(&name, &svc.name, &all_service_names) {
150 continue;
151 }
152 let content = match std::fs::read_to_string(&path) {
153 Ok(c) => c,
154 Err(_) => continue,
155 };
156 if content.contains(&marker) {
157 continue;
158 }
159 let updated =
160 generate::bundle::inject_networks(&content, std::slice::from_ref(&network_name));
161 steps.push(Step::WriteFile(GeneratedFile {
162 path,
163 content: updated,
164 }));
165 let unit = name.trim_end_matches(".container").to_string();
168 units_to_restart.push(unit);
169 }
170 if !units_to_restart.is_empty() {
171 steps.push(Step::DaemonReload);
172 for unit in units_to_restart {
173 steps.push(Step::RestartService { unit });
174 }
175 }
176 }
177 steps
178}
179
180fn service_uses_smtp_relay(service_name: &str, relay_host: &str) -> bool {
186 let env_path = match service_home(service_name) {
187 Ok(h) => h.join(".env"),
188 Err(_) => return false,
189 };
190 let content = match std::fs::read_to_string(&env_path) {
191 Ok(c) => c,
192 Err(_) => return false,
193 };
194 let with_port = format!("{relay_host}:");
195 content.lines().any(|line| {
196 let Some((_, value)) = line.split_once('=') else {
197 return false;
198 };
199 let v = value.trim();
200 v == relay_host || v.starts_with(&with_port)
201 })
202}
203
204#[allow(clippy::too_many_arguments)]
216fn resolve_extra_networks(
217 service_name: &str,
218 enable_auth: bool,
219 authelia_installed: bool,
220 caddy_installed: bool,
221 inbucket_installed: bool,
222 has_url: bool,
223 has_smtp: bool,
224) -> Vec<String> {
225 let mut networks = Vec::new();
226 if enable_auth && authelia_installed && !WellKnownService::Authelia.matches(service_name) {
227 networks.push(WellKnownService::Authelia.to_string());
228 }
229 let joins_inbucket =
232 has_smtp && inbucket_installed && !WellKnownService::Inbucket.matches(service_name);
233 if joins_inbucket {
234 networks.push(WellKnownService::Inbucket.to_string());
235 }
236 let joins_caddy = (has_url || enable_auth || WellKnownService::Inbucket.matches(service_name))
237 && caddy_installed
238 && !WellKnownService::Caddy.matches(service_name);
239 if joins_caddy && !networks.contains(&WellKnownService::Caddy.to_string()) {
240 networks.push(WellKnownService::Caddy.to_string());
241 }
242 networks
243}
244
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
252pub enum PlanMode {
253 Add,
257 Upgrade,
261}
262
263#[allow(clippy::too_many_arguments)]
269pub fn add_service(
270 service_name: &str,
271 exposure: &Exposure,
272 auth_kind: Option<registry::service_def::AuthKind>,
273 enable_auth: bool,
274 enable_smtp: bool,
275 enable_backup: bool,
276 env_overrides: &BTreeMap<String, String>,
277 enabled_groups: &std::collections::BTreeSet<String>,
278 registry_name: &str,
279 repo_dir: &Path,
280 pre_built_ctx: Option<BTreeMap<String, String>>,
281 port_in_use: &dyn Fn(u16) -> bool,
282 acme_mode: Option<&caddy::AcmeMode>,
283 mode: PlanMode,
284 port_overrides: &BTreeMap<String, u16>,
290) -> Result<AddResult> {
291 let url: Option<&str> = exposure.url();
298 let tailscale_enabled: bool = exposure.is_tailscale();
299 let paths = ConfigPaths::resolve()?;
300 let config = config::load_or_default(&paths.config_file)?;
301
302 if mode == PlanMode::Add {
308 if is_service_installed(service_name) {
309 return Err(Error::ServiceAlreadyInstalled(service_name.to_string()));
310 }
311
312 if data::enumerate_service(service_name)?.is_some() {
320 return Err(Error::ServiceIncomplete(service_name.to_string()));
321 }
322 }
323
324 let reg_service = registry::find_service(repo_dir, service_name)?;
325
326 if let Some(msg) = reg_service.def.check_architecture() {
328 return Err(Error::UnsupportedArchitecture(msg));
329 }
330
331 let missing_requires: Vec<&str> = reg_service
333 .def
334 .requires
335 .iter()
336 .filter(|r| !is_service_installed(&r.service))
337 .map(|r| r.service.as_str())
338 .collect();
339 if !missing_requires.is_empty() {
340 return Err(Error::MissingRequiredServices {
341 service: service_name.to_string(),
342 missing: missing_requires.iter().map(|s| s.to_string()).collect(),
343 });
344 }
345
346 if auth_kind.is_some() && config.auth.is_none() {
348 return Err(Error::AuthNotConfigured);
349 }
350
351 if enable_auth
355 && reg_service.def.integrations.auth.is_empty()
356 && !capability::def_provides(®_service.def, Capability::OidcProvider)
357 {
358 return Err(Error::NoOidcSupport(service_name.to_string()));
359 }
360
361 if enable_backup && !reg_service.def.integrations.backup {
365 return Err(Error::BackupNotSupported(service_name.to_string()));
366 }
367
368 for g in enabled_groups {
372 if !reg_service.def.env_groups.iter().any(|eg| &eg.name == g) {
373 let known: Vec<String> = reg_service
374 .def
375 .env_groups
376 .iter()
377 .map(|eg| eg.name.clone())
378 .collect();
379 let hint = if known.is_empty() {
380 " (service defines no env_groups)".to_string()
381 } else {
382 format!(" (known: {})", known.join(", "))
383 };
384 return Err(Error::UnknownEnvGroup {
385 service: service_name.to_string(),
386 group: g.clone(),
387 hint,
388 });
389 }
390 }
391
392 let mut port_warnings: Vec<Warning> = Vec::new();
398 let mut claimed: std::collections::HashSet<u16> = reg_service
399 .def
400 .ports
401 .iter()
402 .filter_map(|p| p.host_port)
403 .collect();
404 let mut resolved_ports: Vec<(String, u16)> = Vec::with_capacity(reg_service.def.ports.len());
405 for p in ®_service.def.ports {
406 let host = if let Some(pinned) = port_overrides.get(&p.name) {
407 *pinned
412 } else if let Some(hp) = p.host_port {
413 hp
414 } else {
415 let privileged = p.container_port < 1024;
416 let claimed_in_service = claimed.contains(&p.container_port);
417 let in_use = port_in_use(p.container_port);
418 if privileged || claimed_in_service || in_use {
419 let allocated = system::port::allocate_port_excluding(&claimed, port_in_use)?;
420 let reason = if privileged {
421 "port is privileged (requires root)".to_string()
422 } else if claimed_in_service {
423 format!(
424 "port {} is already claimed by another port in this service",
425 p.container_port
426 )
427 } else {
428 format!("port {} is already in use", p.container_port)
429 };
430 port_warnings.push(Warning::PortReassigned {
431 service_name: service_name.to_string(),
432 port_name: p.name.clone(),
433 original_port: p.container_port,
434 assigned_port: allocated,
435 reason,
436 });
437 allocated
438 } else {
439 p.container_port
440 }
441 };
442 claimed.insert(host);
443 resolved_ports.push((p.name.clone(), host));
444 }
445
446 if WellKnownService::Caddy.matches(service_name)
453 && system::sysctl::rootless_can_bind_low_ports()
454 {
455 for (name, port) in resolved_ports.iter_mut() {
456 match name.as_str() {
457 "http" if *port == 8080 => *port = 80,
458 "https" if *port == 8443 => *port = 443,
459 _ => {}
460 }
461 }
462 }
463
464 let host_port = resolved_ports
467 .iter()
468 .find(|(name, _)| name.eq_ignore_ascii_case("http"))
469 .or_else(|| resolved_ports.first())
470 .map(|(_, p)| *p);
471
472 for (_, port) in &resolved_ports {
476 if port_in_use(*port) {
477 return Err(Error::PortConflict { port: *port });
478 }
479 }
480
481 let home_dir = service_home(service_name)?;
482 let quadlet_path = quadlet_dir()?;
483
484 let installed_now = list_installed().unwrap_or_default();
488 let authelia_installed =
489 find_installed_provider(&installed_now, Capability::OidcProvider).is_some();
490 let caddy_installed =
491 find_installed_provider(&installed_now, Capability::ReverseProxy).is_some();
492 let inbucket_installed =
493 find_installed_provider(&installed_now, Capability::SmtpRelay).is_some();
494
495 let auth_bridge = auth_bridge::build(&auth_bridge::AuthBridgeParams {
499 service_name,
500 service_provides: ®_service.def.capabilities.provides,
501 enable_auth,
502 config: &config,
503 installed: &installed_now,
504 service_data: &home_dir,
505 })?;
506
507 let (extra_volumes, extra_env, extra_exec_start_pre, auth_bridge_steps) = match auth_bridge {
508 Some(b) => (b.volumes, b.env, b.exec_start_pre, b.steps),
509 None => (Vec::new(), BTreeMap::new(), Vec::new(), Vec::new()),
510 };
511
512 let has_smtp = enable_smtp
513 && reg_service.def.integrations.smtp
514 && !reg_service.def.mappings.smtp.is_empty()
515 && config.smtp.is_some();
516 let extra_networks = resolve_extra_networks(
517 service_name,
518 enable_auth,
519 authelia_installed,
520 caddy_installed,
521 inbucket_installed,
522 url.is_some(),
523 has_smtp,
524 );
525
526 let output = generate::generate_env(generate::GenerateEnvParams {
527 config: &config,
528 service_def: ®_service.def,
529 auth_kind: auth_kind.as_ref(),
530 host_port,
531 resolved_ports: &resolved_ports,
532 env_overrides,
533 url,
534 extra_env,
535 pre_built_ctx,
536 enable_smtp: has_smtp,
537 enabled_groups,
538 })?;
539
540 let podman_args: Vec<String> = Vec::new();
541
542 let port_vars: Vec<(String, String)> = resolved_ports
545 .iter()
546 .map(|(name, port)| {
547 (
548 format!("SERVICE_PORT_{}", name.to_uppercase()),
549 port.to_string(),
550 )
551 })
552 .collect();
553
554 let install_metadata = Metadata {
559 registry: registry_name.to_string(),
560 url: url.map(str::to_string),
561 auth: auth_kind.clone(),
562 provides: reg_service.def.capabilities.provides.clone(),
563 backup_enabled: enable_backup,
564 };
565
566 let bundle =
568 generate::bundle::process_quadlet_bundle(&generate::bundle::ProcessBundleParams {
569 service_dir: ®_service.service_dir,
570 service_name,
571 extra_networks: &extra_networks,
572 extra_volumes: &extra_volumes,
573 podman_args: &podman_args,
574 extra_exec_start_pre: &extra_exec_start_pre,
575 port_vars: &port_vars,
576 })?;
577
578 let mut warnings = Vec::new();
580
581 if let Some(ref reqs) = reg_service.def.requirements
582 && let Some(total) = system::memory::total_ram_mb()
583 {
584 if total < reqs.ram.min {
585 warnings.push(Warning::RamBelowMinimum {
586 service_name: service_name.to_string(),
587 min_mb: reqs.ram.min,
588 available_mb: total,
589 });
590 } else if let Some(rec) = reqs.ram.recommended
591 && total < rec
592 {
593 warnings.push(Warning::RamBelowRecommended {
594 service_name: service_name.to_string(),
595 recommended_mb: rec,
596 available_mb: total,
597 });
598 }
599 }
600 warnings.extend(port_warnings);
601
602 let mut steps = Vec::new();
604
605 steps.push(Step::CreateDir(home_dir.clone()));
607
608 let env_content = output.env_file.content.clone();
610
611 for image in &bundle.images {
613 steps.push(Step::PullImage {
614 image: image.clone(),
615 });
616 }
617
618 for file in bundle.quadlet_files {
622 let link = file
623 .path
624 .file_name()
625 .map(|n| quadlet_path.join(n))
626 .ok_or_else(|| {
627 Error::Bundle(format!("invalid quadlet path: {}", file.path.display()))
628 })?;
629 let target = file.path.clone();
630 steps.push(Step::WriteFile(file));
631 steps.push(Step::Symlink { link, target });
632 }
633
634 let metadata_content = toml::to_string_pretty(&install_metadata)?;
641 steps.push(Step::WriteFile(GeneratedFile {
642 path: metadata_path(service_name)?,
643 content: metadata_content,
644 }));
645
646 if mode == PlanMode::Add
654 && tailscale_enabled
655 && let Some(port) = host_port
656 {
657 let svc_name = exposure.tailscale_svc_name().ok_or_else(|| {
666 Error::InvalidServiceRef(format!(
667 "tailscale exposure for '{service_name}' has a malformed URL — \
668 expected `https://<service>-<host>.<tailnet>.ts.net/`"
669 ))
670 })?;
671 steps.push(Step::TailscaleSetup);
672 steps.push(Step::TailscaleEnable {
673 svc_name,
674 host_port: port,
675 });
676 }
677
678 for file in bundle.config_files {
680 steps.push(Step::WriteFile(file));
681 }
682
683 for (src, dst) in bundle.files {
687 steps.push(Step::CopyFile { src, dst });
688 }
689
690 steps.push(Step::WriteFile(output.env_file));
692
693 for dir in &bundle.bind_mount_dirs {
695 steps.push(Step::CreateDir(dir.clone()));
696 }
697
698 steps.extend(auth_bridge_steps);
702
703 if mode == PlanMode::Add
712 && let (
713 Some(registry::service_def::AuthKind::Oidc),
714 Some(config::schema::AuthCredentials::Authelia { .. }),
715 ) = (auth_kind.as_ref(), config.auth.as_ref())
716 {
717 steps.extend(authelia::register_oidc_client(
718 service_name,
719 ®_service.def,
720 url,
721 &output.ctx,
722 &config,
723 &quadlet_path,
724 )?);
725 }
726
727 if let Some(url) = url
733 && !WellKnownService::Caddy.matches(service_name)
734 && !exposure.is_tailscale()
735 {
736 if caddy_installed {
737 let parsed = url::Url::parse(url)
738 .map_err(|e| Error::Template(format!("invalid service URL '{url}': {e}")))?;
739 let domain = parsed.host_str().ok_or_else(|| {
740 Error::Template(format!(
741 "service URL '{url}' has no host — Caddy needs a hostname to route to"
742 ))
743 })?;
744 let container_port = reg_service
745 .def
746 .ports
747 .first()
748 .map(|p| p.container_port)
749 .unwrap_or(80);
750 let primary_quadlet = reg_service
751 .service_dir
752 .join("quadlets")
753 .join(format!("{service_name}.container"));
754 let target_host = caddy::primary_container_name(&primary_quadlet, service_name);
755 let block = caddy::render_site_block(&caddy::CaddySiteParams {
756 service_name: service_name.to_string(),
757 target_host,
758 domain: domain.to_string(),
759 container_port,
760 https_port: caddy_https_port(&config),
761 });
762 let caddyfile_path = caddy::caddyfile_path()?;
763 let existing =
764 std::fs::read_to_string(&caddyfile_path).map_err(|source| Error::FileRead {
765 path: caddyfile_path.clone(),
766 source,
767 })?;
768 let updated = caddy::add_route(&existing, service_name, &block);
769 steps.push(Step::WriteFile(GeneratedFile {
770 path: caddyfile_path,
771 content: updated,
772 }));
773 steps.push(Step::ReloadCaddy);
774 } else if let Some(primary) = host_port {
775 warnings.push(Warning::UrlWithoutReverseProxy {
780 service_name: service_name.to_string(),
781 url: url.to_string(),
782 host_port: primary,
783 });
784 }
785 }
786
787 if mode == PlanMode::Add {
797 steps.extend(retroactive_network_joins(
798 service_name,
799 &quadlet_path,
800 Some(repo_dir),
801 ));
802 }
803
804 if WellKnownService::Caddy.matches(service_name) {
811 let snippet_path = caddy::tls_snippet_path()?;
812 if !snippet_path.exists() {
813 let mode = acme_mode.cloned().unwrap_or(caddy::AcmeMode::Internal);
814 steps.push(Step::WriteFile(GeneratedFile {
815 path: snippet_path,
816 content: mode.snippet(),
817 }));
818 }
819 }
820
821 let manifest_path_for_svc = manifest::manifest_path(service_name)?;
830 let env_filename = std::ffi::OsStr::new(".env");
831 let mut manifest_entries: Vec<manifest::ManifestEntry> = Vec::new();
832 for step in &steps {
833 if let Step::WriteFile(file) = step {
834 if file.path == manifest_path_for_svc {
835 continue;
836 }
837 if file.path.file_name() == Some(env_filename) {
838 continue;
839 }
840 manifest_entries.push(manifest::ManifestEntry {
841 path: file.path.clone(),
842 sha256: manifest::hash_bytes(file.content.as_bytes()),
843 });
844 }
845 }
846 let tracked_envs = collect_static_envs(®_service.def, &output.ctx, enabled_groups)?;
854 let manifest_envs: Vec<manifest::EnvEntry> = tracked_envs
855 .iter()
856 .map(|t| manifest::EnvEntry {
857 key: t.key.clone(),
858 value: t.value.clone(),
859 })
860 .collect();
861 steps.push(Step::WriteFile(GeneratedFile {
862 path: manifest_path_for_svc,
863 content: manifest::format(&manifest_entries, &manifest_envs),
864 }));
865
866 steps.push(Step::DaemonReload);
868 steps.push(Step::StartService {
870 unit: service_name.to_string(),
871 });
872
873 let allocated_ports: Vec<(String, u16)> = resolved_ports.clone();
875
876 let mut generated_secrets: Vec<String> = reg_service
878 .def
879 .env
880 .iter()
881 .filter(|e| !env_overrides.contains_key(&e.name))
882 .flat_map(|e| generate::extract_secret_refs(&e.value))
883 .collect();
884 generated_secrets.sort();
886 generated_secrets.dedup();
887
888 Ok(AddResult {
889 steps,
890 warnings,
891 repo_url: registry_name.to_string(),
892 allocated_ports,
893 generated_secrets,
894 env_content,
895 url: url.map(|u| u.to_string()),
896 tracked_envs,
897 })
898}
899
900pub fn quadlet_belongs_to(filename: &str, service_name: &str, all_service_names: &[&str]) -> bool {
909 if !filename.starts_with(service_name) {
910 return false;
911 }
912 let rest = &filename[service_name.len()..];
913 if rest.starts_with('.') {
914 return true;
915 }
916 if !rest.starts_with('-') {
917 return false;
918 }
919 !all_service_names.iter().any(|&other| {
923 other.len() > service_name.len()
924 && other.starts_with(service_name)
925 && filename.starts_with(other)
926 && filename[other.len()..].starts_with(['.', '-'])
927 })
928}
929
930#[derive(Debug, Clone, Copy, PartialEq, Eq)]
932pub enum RemoveMode {
933 Preserve,
938 Purge,
940}
941
942pub fn remove_service(service_name: &str, mode: RemoveMode) -> Result<RemoveResult> {
944 let installed_owned = build_installed_from_metadata(service_name)
947 .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
948 let installed = &installed_owned;
949
950 let quadlet_path = quadlet_dir()?;
953 let mut steps = Vec::new();
954 let mut volume_names = Vec::new();
955 let mut has_named_volumes = false;
956 let name_pool = scan_managed_services().unwrap_or_default();
960 let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
961
962 if let Some(svc_name) = installed.exposure.tailscale_svc_name() {
974 steps.push(Step::TailscaleDisable { svc_name });
975 }
976
977 if quadlet_path.is_dir()
978 && let Ok(entries) = std::fs::read_dir(&quadlet_path)
979 {
980 for entry in entries.flatten() {
981 let file_name = entry.file_name();
982 let name = file_name.to_string_lossy();
983 if !quadlet_belongs_to(&name, service_name, &all_names) {
986 continue;
987 }
988 if name.ends_with(".container") {
990 let unit = name.trim_end_matches(".container").to_string();
991 steps.push(Step::StopService { unit });
992 }
993 if name.ends_with(".volume") {
994 has_named_volumes = true;
995 if matches!(mode, RemoveMode::Purge) {
996 let vol = name.trim_end_matches(".volume").to_string();
997 volume_names.push(format!("systemd-{vol}"));
999 }
1000 }
1001 steps.push(Step::RemoveFile(entry.path()));
1002 }
1003 }
1004
1005 let had_caddy_route = matches!(
1012 installed.exposure,
1013 Exposure::Internal { .. } | Exposure::Public { .. }
1014 );
1015 if !WellKnownService::Caddy.matches(service_name) && had_caddy_route {
1016 let caddyfile_path = caddy::caddyfile_path()?;
1017 if caddyfile_path.exists() {
1018 let existing =
1019 std::fs::read_to_string(&caddyfile_path).map_err(|source| Error::FileRead {
1020 path: caddyfile_path.clone(),
1021 source,
1022 })?;
1023 let updated = caddy::remove_route(&existing, service_name);
1024 if updated != existing {
1025 steps.push(Step::WriteFile(GeneratedFile {
1026 path: caddyfile_path,
1027 content: updated.clone(),
1028 }));
1029 if !updated.trim().is_empty() {
1032 steps.push(Step::ReloadCaddy);
1033 }
1034 }
1035 }
1036 }
1037
1038 if !WellKnownService::Authelia.matches(service_name)
1039 && matches!(
1040 installed.auth_kind,
1041 Some(registry::service_def::AuthKind::Oidc)
1042 )
1043 {
1044 steps.extend(authelia::unregister_oidc_client(service_name)?);
1045 }
1046
1047 steps.push(Step::DaemonReload);
1049
1050 match mode {
1051 RemoveMode::Purge => {
1052 for vol_name in volume_names {
1054 steps.push(Step::RemoveVolume { name: vol_name });
1055 }
1056 steps.push(Step::RemoveDir(service_home(service_name)?));
1058 }
1059 RemoveMode::Preserve => {
1060 let home = service_home(service_name)?;
1064 let (data, ephemeral) = crate::data::classify::classify_home_dir(&home)?;
1065 for path in ephemeral {
1066 match std::fs::metadata(&path) {
1067 Ok(m) if m.is_dir() => steps.push(Step::RemoveDir(path)),
1068 Ok(_) => steps.push(Step::RemoveFile(path)),
1069 Err(_) => steps.push(Step::RemoveFile(path)),
1073 }
1074 }
1075 if data.is_empty() && !has_named_volumes && home.exists() {
1083 steps.push(Step::RemoveDir(home));
1084 }
1085 }
1086 }
1087
1088 let url = installed.exposure.url().map(|s| s.to_string());
1089
1090 Ok(RemoveResult {
1091 steps,
1092 service_name: service_name.to_string(),
1093 url,
1094 })
1095}
1096
1097pub struct RecordPendingParams<'a> {
1099 pub service_name: &'a str,
1100 pub auth_kind: Option<registry::service_def::AuthKind>,
1101 pub registry_name: &'a str,
1102 pub allocated_ports: &'a [(String, u16)],
1103 pub repo_dir: &'a Path,
1104 pub exposure: &'a Exposure,
1111}
1112
1113pub fn record_pending(params: RecordPendingParams<'_>) -> Result<()> {
1120 let paths = ConfigPaths::resolve()?;
1121 paths.ensure_dirs()?;
1122 let mut config = config::load_or_default(&paths.config_file)?;
1123
1124 if WellKnownService::Authelia.matches(params.service_name) {
1129 config.auth = Some(authelia::auth_config(
1130 params.allocated_ports,
1131 params.exposure.url(),
1132 )?);
1133 config::save_config(&paths.config_file, &config)?;
1134 }
1135
1136 Ok(())
1137}
1138
1139pub fn finalize_remove(service_name: &str) -> Result<()> {
1146 let paths = ConfigPaths::resolve()?;
1147 let mut config = config::load_or_default(&paths.config_file)?;
1148
1149 if WellKnownService::Authelia.matches(service_name)
1150 && let Some(auth) = &config.auth
1151 && auth.provider_name() == "authelia"
1152 {
1153 config.auth = None;
1154 config::save_config(&paths.config_file, &config)?;
1155 }
1156
1157 Ok(())
1158}
1159
1160const SENSITIVE_TEMPLATE_REFS: &[&str] = &[
1179 "{{secret.",
1180 "{{auth.client_id",
1181 "{{auth.client_secret",
1182 "{{smtp.username",
1183 "{{smtp.password",
1184];
1185
1186fn is_static_template(value: &str) -> bool {
1187 !SENSITIVE_TEMPLATE_REFS.iter().any(|s| value.contains(s))
1188}
1189
1190fn collect_static_envs(
1206 service_def: ®istry::service_def::ServiceDef,
1207 ctx: &BTreeMap<String, String>,
1208 enabled_groups: &std::collections::BTreeSet<String>,
1209) -> Result<Vec<plan::TrackedEnv>> {
1210 use registry::service_def::EnvKind;
1211 let mut out: Vec<plan::TrackedEnv> = Vec::new();
1212 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
1213 let push = |name: &str,
1214 value_template: &str,
1215 kind: EnvKind,
1216 prompt: Option<String>,
1217 out: &mut Vec<plan::TrackedEnv>,
1218 seen: &mut std::collections::HashSet<String>|
1219 -> Result<()> {
1220 if !is_static_template(value_template) {
1221 return Ok(());
1222 }
1223 if !seen.insert(name.to_string()) {
1224 return Ok(());
1225 }
1226 let value = generate::template::render(value_template, ctx)?;
1227 out.push(plan::TrackedEnv {
1228 key: name.to_string(),
1229 value,
1230 kind,
1231 prompt,
1232 });
1233 Ok(())
1234 };
1235 for env in &service_def.env {
1236 push(
1237 &env.name,
1238 &env.value,
1239 env.kind.clone(),
1240 env.prompt.clone(),
1241 &mut out,
1242 &mut seen,
1243 )?;
1244 }
1245 for group in &service_def.env_groups {
1246 if !enabled_groups.contains(&group.name) {
1247 continue;
1248 }
1249 for env in &group.env {
1250 push(
1251 &env.name,
1252 &env.value,
1253 env.kind.clone(),
1254 env.prompt.clone(),
1255 &mut out,
1256 &mut seen,
1257 )?;
1258 }
1259 }
1260 if service_def.integrations.smtp && ctx.contains_key("smtp.host") {
1266 for (env_name, value_template) in &service_def.mappings.smtp {
1267 push(
1268 env_name,
1269 value_template,
1270 EnvKind::Default,
1271 None,
1272 &mut out,
1273 &mut seen,
1274 )?;
1275 }
1276 }
1277 if ctx.contains_key("auth.client_id") {
1278 for (env_name, value_template) in &service_def.mappings.auth {
1279 push(
1280 env_name,
1281 value_template,
1282 EnvKind::Default,
1283 None,
1284 &mut out,
1285 &mut seen,
1286 )?;
1287 }
1288 }
1289 Ok(out)
1290}
1291
1292pub fn orphan_purge_steps(svc: &data::ServiceData) -> Vec<Step> {
1293 let mut steps = Vec::new();
1294
1295 let mut had_quadlet = false;
1301 if let Ok(qdir) = quadlet_dir()
1302 && qdir.is_dir()
1303 && let Ok(entries) = std::fs::read_dir(&qdir)
1304 {
1305 let name_pool = scan_managed_services().unwrap_or_default();
1306 let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
1307 for entry in entries.flatten() {
1308 let file_name = entry.file_name();
1309 let name = file_name.to_string_lossy();
1310 if !quadlet_belongs_to(&name, &svc.service, &all_names) {
1311 continue;
1312 }
1313 if name.ends_with(".container") {
1317 let unit = name.trim_end_matches(".container").to_string();
1318 steps.push(Step::StopService { unit });
1319 } else if name.ends_with(".network") {
1320 let unit = format!("{}-network", name.trim_end_matches(".network"));
1321 steps.push(Step::StopService { unit });
1322 } else if name.ends_with(".volume") {
1323 let unit = format!("{}-volume", name.trim_end_matches(".volume"));
1324 steps.push(Step::StopService { unit });
1325 }
1326 steps.push(Step::RemoveFile(entry.path()));
1327 had_quadlet = true;
1328 }
1329 }
1330 if had_quadlet {
1331 steps.push(Step::DaemonReload);
1332 }
1333
1334 for path in &svc.data_paths {
1335 if path.is_dir() {
1336 steps.push(Step::RemoveDir(path.clone()));
1337 } else {
1338 steps.push(Step::RemoveFile(path.clone()));
1339 }
1340 }
1341 if svc.home_dir.exists() {
1342 steps.push(Step::RemoveDir(svc.home_dir.clone()));
1343 }
1344 for v in &svc.volumes {
1345 steps.push(Step::RemoveVolume {
1346 name: v.name.clone(),
1347 });
1348 }
1349 steps
1350}
1351
1352pub fn reset() -> Result<ResetResult> {
1354 let mut steps = Vec::new();
1355
1356 let managed_names = scan_managed_services().unwrap_or_default();
1361
1362 for svc in list_installed().unwrap_or_default() {
1369 if let Some(svc_name) = svc.exposure.tailscale_svc_name() {
1370 steps.push(Step::TailscaleDisable { svc_name });
1371 }
1372 }
1373
1374 let quadlet_path = quadlet_dir()?;
1376 let all_names: Vec<&str> = managed_names.iter().map(|s| s.as_str()).collect();
1377 if quadlet_path.is_dir()
1378 && let Ok(entries) = std::fs::read_dir(&quadlet_path)
1379 {
1380 for entry in entries.flatten() {
1381 let file_name = entry.file_name();
1382 let name = file_name.to_string_lossy();
1383 let is_ryra_file = managed_names
1387 .iter()
1388 .any(|svc| quadlet_belongs_to(&name, svc, &all_names));
1389 if !is_ryra_file {
1390 continue;
1391 }
1392 if name.ends_with(".container") {
1393 let unit = name.trim_end_matches(".container").to_string();
1394 steps.push(Step::StopService { unit });
1395 }
1396 if name.ends_with(".network") {
1397 let unit = format!("{}-network", name.trim_end_matches(".network"));
1398 steps.push(Step::StopService { unit });
1399 }
1400 if name.ends_with(".volume") {
1401 let vol = name.trim_end_matches(".volume").to_string();
1402 steps.push(Step::StopService {
1409 unit: format!("{vol}-volume"),
1410 });
1411 }
1412 steps.push(Step::RemoveFile(entry.path()));
1413 }
1414 }
1415
1416 steps.push(Step::DaemonReload);
1418
1419 let mut seen_volumes = std::collections::BTreeSet::new();
1425 for svc in data::enumerate_all().unwrap_or_default() {
1426 for vol in svc.volumes {
1427 if seen_volumes.insert(vol.name.clone()) {
1428 steps.push(Step::RemoveVolume { name: vol.name });
1429 }
1430 }
1431 }
1432
1433 let data_root = service_data_root()?;
1439 if data_root.exists() {
1440 steps.push(Step::RemoveDir(data_root));
1441 }
1442
1443 Ok(ResetResult { steps })
1444}
1445
1446pub fn finalize_reset() -> Result<()> {
1448 let paths = ConfigPaths::resolve()?;
1449 if paths.config_dir.exists() {
1450 std::fs::remove_dir_all(&paths.config_dir).map_err(|source| Error::FileWrite {
1451 path: paths.config_dir,
1452 source,
1453 })?;
1454 }
1455 Ok(())
1456}
1457
1458pub fn status() -> config::status::RyraStatus {
1464 let paths = match ConfigPaths::resolve() {
1465 Ok(p) => p,
1466 Err(_) => return config::status::RyraStatus::NotInitialized,
1467 };
1468
1469 let has_quadlets = scan_managed_services()
1470 .map(|n| !n.is_empty())
1471 .unwrap_or(false);
1472
1473 let config = match config::load_config(&paths.config_file) {
1474 Ok(c) => c,
1475 Err(Error::ConfigNotFound(_)) if has_quadlets => config::schema::Config::default(),
1476 Err(Error::ConfigNotFound(_)) => return config::status::RyraStatus::NotInitialized,
1477 Err(e) => return config::status::RyraStatus::Error(e.to_string()),
1478 };
1479
1480 config::status::RyraStatus::Initialized(config::status::StatusInfo::from_config(
1481 paths.config_file,
1482 &config,
1483 ))
1484}
1485
1486pub fn is_service_installed(name: &str) -> bool {
1494 let has_quadlet = scan_managed_services()
1495 .map(|names| names.iter().any(|n| n == name))
1496 .unwrap_or(false);
1497 if !has_quadlet {
1498 return false;
1499 }
1500 metadata_path(name).map(|p| p.exists()).unwrap_or(false)
1501}
1502
1503pub fn scan_managed_services() -> Result<Vec<String>> {
1516 let dir = match quadlet_dir() {
1517 Ok(d) => d,
1518 Err(_) => return Ok(Vec::new()),
1519 };
1520 let entries = match std::fs::read_dir(&dir) {
1521 Ok(e) => e,
1522 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
1523 Err(source) => return Err(Error::FileRead { path: dir, source }),
1524 };
1525 let mut names: Vec<String> = Vec::new();
1526 for entry in entries.flatten() {
1527 let path = entry.path();
1528 if path.extension().and_then(|e| e.to_str()) != Some("container") {
1529 continue;
1530 }
1531 let Ok(content) = std::fs::read_to_string(&path) else {
1532 continue;
1533 };
1534 for line in content.lines().take(16) {
1535 if let Some(rest) = line.trim().strip_prefix("# Service-Source: registry/")
1536 && !rest.is_empty()
1537 && !names.iter().any(|n| n == rest)
1538 {
1539 names.push(rest.to_string());
1540 break;
1541 }
1542 }
1543 }
1544 names.sort();
1545 Ok(names)
1546}
1547
1548fn build_installed_from_metadata(service_name: &str) -> Option<InstalledService> {
1554 let meta = load_metadata(service_name).ok().flatten()?;
1555
1556 let exposure = match meta.url.as_deref() {
1558 None => Exposure::Loopback,
1559 Some(u) => Exposure::from_url(u),
1560 };
1561
1562 let auth_kind = meta.auth.clone();
1563
1564 let ports = service_home(service_name)
1570 .ok()
1571 .and_then(|home| std::fs::read_to_string(home.join(".env")).ok())
1572 .map(|env| {
1573 env.lines()
1574 .filter_map(|l| {
1575 let l = l.trim();
1576 if l.is_empty() || l.starts_with('#') {
1577 return None;
1578 }
1579 let (key, val) = l.split_once('=')?;
1580 let name = key.strip_prefix("SERVICE_PORT_")?.to_lowercase();
1581 let port = val
1582 .trim_matches(|c: char| c == '"' || c == '\'')
1583 .parse::<u16>()
1584 .ok()?;
1585 Some((name, port))
1586 })
1587 .collect::<std::collections::BTreeMap<String, u16>>()
1588 })
1589 .unwrap_or_default();
1590
1591 Some(InstalledService {
1592 name: service_name.to_string(),
1593 version: "0.1.0".to_string(),
1594 repo: meta.registry,
1595 ports,
1596 auth_kind,
1597 exposure,
1598 provides: meta.provides,
1599 installed: true,
1600 })
1601}
1602
1603pub fn list_installed() -> Result<Vec<InstalledService>> {
1610 let names = scan_managed_services().unwrap_or_default();
1611 let out: Vec<InstalledService> = names
1612 .iter()
1613 .filter_map(|n| build_installed_from_metadata(n))
1614 .collect();
1615 Ok(out)
1616}
1617
1618pub fn search_services(repo_dir: &Path, query: Option<&str>) -> Result<Vec<SearchResult>> {
1620 let available = registry::list_available(repo_dir)?;
1621
1622 let results = available
1623 .into_iter()
1624 .filter(|reg_svc| match query {
1625 None => true,
1626 Some(q) => {
1627 let q = q.to_lowercase();
1628 reg_svc.def.service.name.to_lowercase().contains(&q)
1629 || reg_svc.def.service.description.to_lowercase().contains(&q)
1630 }
1631 })
1632 .map(|reg_svc| {
1633 let name = ®_svc.def.service.name;
1634 let installed = is_service_installed(name);
1635 let mut supports = Vec::new();
1636 for kind in ®_svc.def.integrations.auth {
1637 supports.push(kind.to_string());
1638 }
1639 if reg_svc.def.integrations.smtp {
1640 supports.push("smtp".to_string());
1641 }
1642 SearchResult {
1643 name: name.clone(),
1644 description: reg_svc.def.service.description,
1645 installed,
1646 supports,
1647 }
1648 })
1649 .collect();
1650
1651 Ok(results)
1652}
1653
1654pub struct SearchResult {
1655 pub name: String,
1656 pub description: String,
1657 pub installed: bool,
1658 pub supports: Vec<String>,
1660}
1661
1662pub async fn service_tests(service_name: &str) -> Result<ServiceTestInfo> {
1664 let installed = build_installed_from_metadata(service_name)
1665 .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
1666
1667 let service_ref = service_ref_from_installed(&installed);
1668 let repo_dir = resolve_registry_dir(&service_ref).await?;
1669
1670 let test_toml_path = repo_dir.join(service_name).join("test.toml");
1671 let env_file = service_home(service_name)?.join(".env");
1672
1673 if !test_toml_path.exists() {
1674 return Ok(ServiceTestInfo {
1675 service_name: service_name.to_string(),
1676 registry_name: service_ref.registry_name().to_string(),
1677 tests: vec![],
1678 env_file,
1679 });
1680 }
1681
1682 let content = std::fs::read_to_string(&test_toml_path).map_err(|source| Error::FileRead {
1683 path: test_toml_path.clone(),
1684 source,
1685 })?;
1686
1687 #[derive(serde::Deserialize)]
1688 struct TestFile {
1689 #[serde(default)]
1690 tests: Vec<registry::test_def::TestDef>,
1691 }
1692
1693 let parsed: TestFile = toml::from_str(&content).map_err(|source| Error::TomlParse {
1694 path: test_toml_path,
1695 source,
1696 })?;
1697
1698 Ok(ServiceTestInfo {
1699 service_name: service_name.to_string(),
1700 registry_name: service_ref.registry_name().to_string(),
1701 tests: parsed.tests,
1702 env_file,
1703 })
1704}
1705
1706pub struct ServiceTestInfo {
1707 pub service_name: String,
1708 pub registry_name: String,
1709 pub tests: Vec<registry::test_def::TestDef>,
1710 pub env_file: PathBuf,
1711}
1712
1713pub fn service_info(repo_dir: &Path, service_name: &str) -> Result<ServiceDetail> {
1715 let reg_service = registry::find_service(repo_dir, service_name)?;
1716 let def = ®_service.def;
1717
1718 Ok(ServiceDetail {
1719 name: def.service.name.clone(),
1720 description: def.service.description.clone(),
1721 url: def.service.url.clone(),
1722 ports: def
1723 .ports
1724 .iter()
1725 .map(|p| (p.container_port, p.protocol.clone(), p.name.clone()))
1726 .collect(),
1727 env_vars: def
1728 .env
1729 .iter()
1730 .map(|e| (e.name.clone(), e.prompt.clone()))
1731 .collect(),
1732 })
1733}
1734
1735pub struct ServiceDetail {
1736 pub name: String,
1737 pub description: String,
1738 pub url: Option<String>,
1739 pub ports: Vec<(u16, registry::service_def::PortProtocol, String)>,
1740 pub env_vars: Vec<(String, Option<String>)>,
1741}
1742
1743#[cfg(test)]
1744mod tests {
1745 use super::*;
1746
1747 #[test]
1748 fn static_template_filter_excludes_secrets_and_credentials() {
1749 assert!(is_static_template("3306"));
1751 assert!(is_static_template("mariadb"));
1752 assert!(is_static_template("{{service.port}}"));
1754 assert!(is_static_template("{{service.url}}"));
1755 assert!(is_static_template("{{auth.url}}"));
1756 assert!(is_static_template("{{auth.issuer}}"));
1757 assert!(is_static_template("{{auth.provider}}"));
1758 assert!(is_static_template("{{auth.internal_url}}"));
1759 assert!(is_static_template("{{smtp.host}}"));
1760 assert!(is_static_template("{{smtp.port}}"));
1761 assert!(is_static_template("{{smtp.from}}"));
1762 assert!(is_static_template("{{service.url}}/oauth/callback"));
1764
1765 assert!(!is_static_template("{{secret.admin_password}}"));
1767 assert!(!is_static_template("{{secret.jwt_key}}"));
1768 assert!(!is_static_template("{{auth.client_id}}"));
1770 assert!(!is_static_template("{{auth.client_secret}}"));
1771 assert!(!is_static_template("{{smtp.username}}"));
1773 assert!(!is_static_template("{{smtp.password}}"));
1774 assert!(!is_static_template(
1776 "redis://:{{secret.redis_pw}}@host:6379"
1777 ));
1778 }
1779
1780 #[test]
1781 fn tailscale_url_matches() {
1782 assert!(is_tailscale_url("http://debian.cobbler-tuna.ts.net"));
1783 assert!(is_tailscale_url("http://debian.cobbler-tuna.ts.net:10001/"));
1784 assert!(is_tailscale_url("https://foo.example-net.ts.net"));
1785 assert!(is_tailscale_url("http://HOST.COBBLER-TUNA.TS.NET"));
1786 }
1787
1788 #[test]
1789 fn tailscale_url_rejects() {
1790 assert!(!is_tailscale_url("https://nextcloud.internal:8443"));
1791 assert!(!is_tailscale_url("https://example.com"));
1792 assert!(!is_tailscale_url("http://127.0.0.1:10001"));
1793 assert!(!is_tailscale_url("https://ts.net"));
1795 assert!(!is_tailscale_url("https://evil-ts.net.example.com"));
1796 assert!(!is_tailscale_url("not a url"));
1797 }
1798
1799 #[test]
1800 fn public_url_accepts_public_domains() {
1801 assert!(is_public_url("https://seafile.ryra.no"));
1802 assert!(is_public_url("https://example.com"));
1803 assert!(is_public_url("https://docs.ryra.no:8443"));
1804 }
1805
1806 #[test]
1807 fn public_url_rejects_lan_and_tailnet() {
1808 assert!(!is_public_url("https://nextcloud.internal:8443"));
1809 assert!(!is_public_url("https://service.localhost"));
1810 assert!(!is_public_url("https://something.local"));
1811 assert!(!is_public_url("https://localhost:8080"));
1812 assert!(!is_public_url("https://debian.cobbler-tuna.ts.net"));
1813 assert!(!is_public_url("http://127.0.0.1:10001"));
1814 assert!(!is_public_url("http://192.168.1.10"));
1815 assert!(!is_public_url("http://[::1]"));
1816 assert!(!is_public_url("not a url"));
1817 }
1818
1819 #[test]
1824 fn networks_empty_when_no_auth() {
1825 let nets = resolve_extra_networks("whoami", false, false, false, false, false, false);
1826 assert!(nets.is_empty());
1827 }
1828
1829 #[test]
1830 fn networks_empty_when_auth_but_no_authelia() {
1831 let nets = resolve_extra_networks("forgejo", true, false, false, false, false, false);
1832 assert!(nets.is_empty());
1833 }
1834
1835 #[test]
1836 fn networks_authelia_when_auth_enabled() {
1837 let nets = resolve_extra_networks("forgejo", true, true, false, false, false, false);
1838 assert_eq!(nets, vec!["authelia"]);
1839 }
1840
1841 #[test]
1842 fn networks_auth_with_caddy_includes_both() {
1843 let nets = resolve_extra_networks("forgejo", true, true, true, false, false, false);
1844 assert!(nets.contains(&"authelia".to_string()));
1845 assert!(nets.contains(&"caddy".to_string()));
1846 }
1847
1848 #[test]
1849 fn networks_authelia_excluded_for_authelia_itself() {
1850 let nets = resolve_extra_networks("authelia", true, true, false, false, false, false);
1851 assert!(nets.is_empty());
1852 }
1853
1854 #[test]
1855 fn networks_smtp_joins_inbucket_without_caddy() {
1856 let nets = resolve_extra_networks("forgejo", false, false, false, true, false, true);
1858 assert_eq!(nets, vec!["inbucket"]);
1859 }
1860
1861 #[test]
1862 fn networks_smtp_skips_inbucket_when_it_is_self() {
1863 let nets = resolve_extra_networks("inbucket", false, false, false, true, false, true);
1864 assert!(!nets.contains(&"inbucket".to_string()));
1865 }
1866
1867 #[test]
1868 fn networks_smtp_skips_inbucket_when_not_installed() {
1869 let nets = resolve_extra_networks("forgejo", false, false, false, false, false, true);
1870 assert!(!nets.contains(&"inbucket".to_string()));
1871 }
1872
1873 #[test]
1874 fn quadlet_belongs_to_exact_match() {
1875 let all = &["foo", "foo-bar"];
1876 assert!(quadlet_belongs_to("foo.container", "foo", all));
1877 assert!(quadlet_belongs_to("foo.network", "foo", all));
1878 }
1879
1880 #[test]
1881 fn quadlet_belongs_to_sidecar() {
1882 let all = &["foo"];
1884 assert!(quadlet_belongs_to("foo-db.volume", "foo", all));
1885 }
1886
1887 #[test]
1888 fn quadlet_belongs_to_rejects_prefix_collision() {
1889 let all = &["foo", "foo-bar"];
1890 assert!(!quadlet_belongs_to("foo-bar.container", "foo", all));
1891 assert!(!quadlet_belongs_to("foo-bar-db.volume", "foo", all));
1892 }
1893
1894 #[test]
1895 fn quadlet_belongs_to_hyphenated_service() {
1896 let all = &["foo", "foo-bar"];
1897 assert!(quadlet_belongs_to("foo-bar.container", "foo-bar", all));
1898 assert!(quadlet_belongs_to("foo-bar-db.volume", "foo-bar", all));
1899 assert!(!quadlet_belongs_to("foo.container", "foo-bar", all));
1900 }
1901}