pub mod auth_bridge;
pub mod authelia;
pub mod backup;
pub mod caddy;
pub mod capability;
pub mod config;
pub mod configure;
pub mod data;
pub mod deploy;
pub mod error;
pub mod exposure;
pub mod generate;
pub mod manifest;
pub mod metadata;
pub mod metrics_bridge;
pub mod ops;
pub mod paths;
pub mod plan;
pub mod registry;
pub mod system;
pub mod upgrade;
pub mod well_known;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use config::ConfigPaths;
use config::schema::InstalledService;
use error::{Error, Result};
pub use capability::{
Capability, any_installed_provider, find_installed_provider, installed_provides,
service_provides,
};
pub use configure::{
ConfigureChange, ConfigureResult, EnvKeyChange, ExposureChange,
Overrides as ConfigureOverrides, ServiceReconcile, configure_service, reconcile_service,
};
pub use exposure::{
Exposure, check_auth_exposure_compat, is_caddy_local_url, is_public_url, is_tailscale_url,
};
pub use generate::GeneratedFile;
pub use manifest::{ManifestEntry, manifest_path};
pub use metadata::{Metadata, load_metadata};
pub use paths::{
CONFIG_DIR_ENV, DATA_DIR_ENV, DEFAULT_REGISTRY_URL, REGISTRY_DEFAULT, REGISTRY_DIR_ENV,
metadata_path, quadlet_dir, service_data_root, service_home, systemd_user_dir,
};
pub use plan::{AddResult, RemoveResult, ResetResult, Step, TailscalePort, TrackedEnv, Warning};
pub use upgrade::{
BackupSnapshot, DEFAULT_BACKUP_KEEP, DiffEntry, DiffKind, DiffResult, EnvAddition,
RevertResult, UpgradeResult, diff_service, list_backups, prune_backups, revert_service,
upgrade_service,
};
pub use well_known::WellKnownService;
pub(crate) use paths::home_dir;
pub(crate) use well_known::caddy_https_port;
pub async fn resolve_registry_dir(service_ref: ®istry::resolve::ServiceRef) -> Result<PathBuf> {
let paths = ConfigPaths::resolve()?;
paths.ensure_cache_dir()?;
let config = config::load_or_default(&paths.config_file)?;
registry::resolve::resolve_registry_dir(service_ref, &config, &paths.cache_dir).await
}
pub fn service_ref_from_installed(installed: &InstalledService) -> registry::resolve::ServiceRef {
if installed.repo.is_empty() || installed.repo == REGISTRY_DEFAULT {
registry::resolve::ServiceRef::Default(installed.name.clone())
} else {
registry::resolve::ServiceRef::Custom {
registry: installed.repo.clone(),
service: installed.name.clone(),
}
}
}
fn retroactive_network_joins(
new_service: &str,
quadlet_path: &std::path::Path,
_repo_dir: Option<&std::path::Path>,
) -> Vec<Step> {
let mut steps = Vec::new();
let new_cap = if service_provides(new_service, Capability::ReverseProxy) {
Capability::ReverseProxy
} else if service_provides(new_service, Capability::SmtpRelay) {
Capability::SmtpRelay
} else {
return steps;
};
let installed = list_installed().unwrap_or_default();
for svc in &installed {
if !svc.provides.is_empty() {
continue;
}
let (network_name, should_join) = match new_cap {
Capability::ReverseProxy => {
let wants_proxy = matches!(
svc.exposure,
Exposure::Internal { .. } | Exposure::Public { .. }
);
(new_service.to_string(), wants_proxy)
}
Capability::SmtpRelay => {
(
new_service.to_string(),
service_uses_smtp_relay(&svc.name, new_service),
)
}
Capability::OidcProvider
| Capability::ForwardAuthProvider
| Capability::MetricsStore
| Capability::MetricsDashboard => {
continue;
}
};
if !should_join {
continue;
}
let installed_names_owned: Vec<String> = installed.iter().map(|s| s.name.clone()).collect();
let all_service_names: Vec<&str> =
installed_names_owned.iter().map(|s| s.as_str()).collect();
steps.extend(network_join_steps(
&svc.name,
&network_name,
quadlet_path,
&all_service_names,
));
}
steps
}
fn network_join_steps(
svc_name: &str,
network_name: &str,
quadlet_path: &std::path::Path,
all_service_names: &[&str],
) -> Vec<Step> {
let mut steps = Vec::new();
let marker = format!("Network={network_name}.network");
let mut units_to_restart: Vec<String> = Vec::new();
let Ok(entries) = std::fs::read_dir(quadlet_path) else {
return steps;
};
for entry in entries.flatten() {
let path = entry.path();
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) if n.ends_with(".container") => n.to_string(),
_ => continue,
};
if !quadlet_belongs_to(&name, svc_name, all_service_names) {
continue;
}
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => continue,
};
if content.contains(&marker) {
continue;
}
let real_path = match std::fs::canonicalize(&path) {
Ok(p) => p,
Err(_) => continue,
};
let updated = generate::bundle::inject_networks(
&content,
std::slice::from_ref(&network_name.to_string()),
);
steps.push(Step::WriteFile(GeneratedFile {
path: real_path,
content: updated,
}));
let unit = name.trim_end_matches(".container").to_string();
units_to_restart.push(unit);
}
if !units_to_restart.is_empty() {
steps.push(Step::DaemonReload);
for unit in units_to_restart {
steps.push(Step::RestartService { unit });
}
}
steps
}
fn store_container_port(store_name: &str) -> Option<u16> {
let def = capability::lookup_registry_def(store_name)?;
def.ports
.iter()
.find(|p| p.name.eq_ignore_ascii_case("http"))
.or_else(|| def.ports.first())
.map(|p| p.container_port)
}
fn retroactive_metrics_wiring(
store_name: &str,
store_def: ®istry::service_def::ServiceDef,
quadlet_path: &std::path::Path,
) -> Vec<Step> {
let mut steps = Vec::new();
let installed = list_installed().unwrap_or_default();
let store_port = store_def
.ports
.iter()
.find(|p| p.name.eq_ignore_ascii_case("http"))
.or_else(|| store_def.ports.first())
.map(|p| p.container_port);
let installed_names_owned: Vec<String> = installed.iter().map(|s| s.name.clone()).collect();
let all_service_names: Vec<&str> = installed_names_owned.iter().map(|s| s.as_str()).collect();
for svc in &installed {
if svc.name == store_name {
continue;
}
let Some(def) = capability::lookup_registry_def(&svc.name) else {
continue;
};
let mut dashboard_wired = false;
let mut needs_join = false;
let metrics_host_port = def.metrics.as_ref().and_then(|m| {
upgrade::read_existing_ports(&svc.name)
.ok()
.and_then(|ports| ports.get(&m.port.to_ascii_lowercase()).copied())
});
if let Ok(Some(step)) =
metrics_bridge::scrape_target_step(store_name, &def, metrics_host_port)
{
steps.push(step);
needs_join = def.metrics.as_ref().is_some_and(|m| !m.host_network);
}
if def
.capabilities
.provides
.contains(&Capability::MetricsDashboard)
&& let Some(port) = store_port
&& let Ok(step) = metrics_bridge::datasource_step(&svc.name, store_name, port)
{
steps.push(step);
dashboard_wired = true;
needs_join = true;
}
if !needs_join {
continue;
}
let join_steps =
network_join_steps(&svc.name, store_name, quadlet_path, &all_service_names);
let restarts_main = join_steps
.iter()
.any(|s| matches!(s, Step::RestartService { unit } if unit == &svc.name));
steps.extend(join_steps);
if dashboard_wired && !restarts_main {
steps.push(Step::RestartService {
unit: svc.name.clone(),
});
}
}
steps
}
fn service_uses_smtp_relay(service_name: &str, relay_host: &str) -> bool {
let env_path = match service_home(service_name) {
Ok(h) => h.join(".env"),
Err(_) => return false,
};
let content = match std::fs::read_to_string(&env_path) {
Ok(c) => c,
Err(_) => return false,
};
let with_port = format!("{relay_host}:");
content.lines().any(|line| {
let Some((_, value)) = line.split_once('=') else {
return false;
};
let v = value.trim();
v == relay_host || v.starts_with(&with_port)
})
}
#[allow(clippy::too_many_arguments)]
fn resolve_extra_networks(
service_name: &str,
enable_auth: bool,
authelia_installed: bool,
caddy_installed: bool,
inbucket_installed: bool,
has_url: bool,
has_smtp: bool,
metrics_store: Option<&str>,
wants_metrics: bool,
) -> Vec<String> {
let mut networks = Vec::new();
if enable_auth && authelia_installed && !WellKnownService::Authelia.matches(service_name) {
networks.push(WellKnownService::Authelia.to_string());
}
let joins_inbucket =
has_smtp && inbucket_installed && !WellKnownService::Inbucket.matches(service_name);
if joins_inbucket {
networks.push(WellKnownService::Inbucket.to_string());
}
let joins_caddy = (has_url || enable_auth || WellKnownService::Inbucket.matches(service_name))
&& caddy_installed
&& !WellKnownService::Caddy.matches(service_name);
if joins_caddy && !networks.contains(&WellKnownService::Caddy.to_string()) {
networks.push(WellKnownService::Caddy.to_string());
}
if let Some(store) = metrics_store
&& wants_metrics
&& store != service_name
{
networks.push(store.to_string());
}
networks
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlanMode {
Add,
Upgrade,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthChoice {
None,
Native(registry::service_def::AuthKind),
}
impl AuthChoice {
pub fn enabled(&self) -> bool {
!matches!(self, AuthChoice::None)
}
pub fn native_kind(&self) -> Option<®istry::service_def::AuthKind> {
match self {
AuthChoice::Native(kind) => Some(kind),
AuthChoice::None => None,
}
}
}
pub struct AddServiceParams<'a> {
pub service_name: &'a str,
pub exposure: &'a Exposure,
pub auth: AuthChoice,
pub enable_smtp: bool,
pub enable_backup: bool,
pub env_overrides: &'a BTreeMap<String, String>,
pub enabled_groups: &'a std::collections::BTreeSet<String>,
pub selected_choices: &'a BTreeMap<String, String>,
pub registry_name: &'a str,
pub repo_dir: &'a Path,
pub pre_built_ctx: Option<BTreeMap<String, String>>,
pub port_in_use: &'a dyn Fn(u16) -> bool,
pub acme_mode: Option<&'a caddy::AcmeMode>,
pub mode: PlanMode,
pub port_overrides: &'a BTreeMap<String, u16>,
}
fn caddy_route_steps(
service_name: &str,
url: &str,
target_host: String,
upstream_port: u16,
host_port: Option<u16>,
caddy_installed: bool,
https_port: u16,
) -> Result<(Vec<Step>, Vec<Warning>)> {
let mut steps = Vec::new();
let mut warnings = Vec::new();
if caddy_installed {
let parsed = url::Url::parse(url)
.map_err(|e| Error::Template(format!("invalid service URL '{url}': {e}")))?;
let domain = parsed.host_str().ok_or_else(|| {
Error::Template(format!(
"service URL '{url}' has no host — Caddy needs a hostname to route to"
))
})?;
let block = caddy::render_site_block(&caddy::CaddySiteParams {
service_name: service_name.to_string(),
target_host,
domain: domain.to_string(),
container_port: upstream_port,
https_port,
force_internal_tls: false,
});
let caddyfile_path = caddy::caddyfile_path()?;
let existing =
std::fs::read_to_string(&caddyfile_path).map_err(|source| Error::FileRead {
path: caddyfile_path.clone(),
source,
})?;
let updated = caddy::add_route(&existing, service_name, &block);
steps.push(Step::WriteFile(GeneratedFile {
path: caddyfile_path,
content: updated,
}));
steps.push(Step::ReloadCaddy);
} else if let Some(primary) = host_port {
warnings.push(Warning::UrlWithoutReverseProxy {
service_name: service_name.to_string(),
url: url.to_string(),
host_port: primary,
});
}
Ok((steps, warnings))
}
pub fn add_service(params: AddServiceParams<'_>) -> Result<AddResult> {
let AddServiceParams {
service_name,
exposure,
auth,
enable_smtp,
enable_backup,
env_overrides,
enabled_groups,
selected_choices,
registry_name,
repo_dir,
pre_built_ctx,
port_in_use,
acme_mode,
mode,
port_overrides,
} = params;
let auth_kind: Option<®istry::service_def::AuthKind> = auth.native_kind();
let enable_auth: bool = auth.enabled();
let url: Option<&str> = exposure.url();
let paths = ConfigPaths::resolve()?;
let config = config::load_or_default(&paths.config_file)?;
if mode == PlanMode::Add {
if is_service_installed(service_name) {
return Err(Error::ServiceAlreadyInstalled(service_name.to_string()));
}
if data::enumerate_service(service_name)?.is_some() {
return Err(Error::ServiceIncomplete(service_name.to_string()));
}
}
let reg_service = registry::find_service(repo_dir, service_name)?;
if let Some(msg) = reg_service.def.check_architecture() {
return Err(Error::UnsupportedArchitecture(msg));
}
let missing_requires: Vec<&str> = reg_service
.def
.requires
.iter()
.filter(|r| !is_service_installed(&r.service))
.map(|r| r.service.as_str())
.collect();
if !missing_requires.is_empty() {
return Err(Error::MissingRequiredServices {
service: service_name.to_string(),
missing: missing_requires.iter().map(|s| s.to_string()).collect(),
});
}
if auth_kind.is_some() && config.auth.is_none() {
return Err(Error::AuthNotConfigured);
}
if enable_auth
&& reg_service.def.integrations.auth.is_empty()
&& !capability::def_provides(®_service.def, Capability::OidcProvider)
{
return Err(Error::NoOidcSupport(service_name.to_string()));
}
if enable_backup && !reg_service.def.integrations.backup {
return Err(Error::BackupNotSupported(service_name.to_string()));
}
for g in enabled_groups {
if !reg_service.def.env_groups.iter().any(|eg| &eg.name == g) {
let known: Vec<String> = reg_service
.def
.env_groups
.iter()
.map(|eg| eg.name.clone())
.collect();
let hint = if known.is_empty() {
" (service defines no env_groups)".to_string()
} else {
format!(" (known: {})", known.join(", "))
};
return Err(Error::UnknownEnvGroup {
service: service_name.to_string(),
group: g.clone(),
hint,
});
}
}
let mut port_warnings: Vec<Warning> = Vec::new();
let mut effective_ports: Vec<®istry::service_def::PortDef> =
reg_service.def.ports.iter().collect();
for choice in ®_service.def.choices {
let sel = selected_choices
.get(&choice.name)
.unwrap_or(&choice.default);
if let Some(opt) = choice.options.iter().find(|o| &o.name == sel) {
effective_ports.extend(opt.ports.iter());
}
}
let mut claimed: std::collections::HashSet<u16> =
effective_ports.iter().filter_map(|p| p.host_port).collect();
let mut resolved_ports: Vec<(String, u16)> = Vec::with_capacity(effective_ports.len());
for p in effective_ports.iter().copied() {
let host = if let Some(pinned) = port_overrides.get(&p.name) {
*pinned
} else if let Some(hp) = p.host_port {
hp
} else {
let privileged = p.container_port < 1024;
let claimed_in_service = claimed.contains(&p.container_port);
let in_use = port_in_use(p.container_port);
if privileged || claimed_in_service || in_use {
let allocated = system::port::allocate_port_excluding(&claimed, port_in_use)?;
let reason = if privileged {
"port is privileged (requires root)".to_string()
} else if claimed_in_service {
format!(
"port {} is already claimed by another port in this service",
p.container_port
)
} else {
format!("port {} is already in use", p.container_port)
};
port_warnings.push(Warning::PortReassigned {
service_name: service_name.to_string(),
port_name: p.name.clone(),
original_port: p.container_port,
assigned_port: allocated,
reason,
});
allocated
} else {
p.container_port
}
};
claimed.insert(host);
resolved_ports.push((p.name.clone(), host));
}
if WellKnownService::Caddy.matches(service_name)
&& system::sysctl::rootless_can_bind_low_ports()
{
for (name, port) in resolved_ports.iter_mut() {
match name.as_str() {
"http" if *port == 8080 => *port = 80,
"https" if *port == 8443 => *port = 443,
_ => {}
}
}
}
let host_port = resolved_ports
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case("http"))
.or_else(|| resolved_ports.first())
.map(|(_, p)| *p);
for (_, port) in &resolved_ports {
if port_in_use(*port) {
return Err(Error::PortConflict { port: *port });
}
}
let blue_green =
reg_service.def.service.deploy == registry::service_def::DeployStrategy::BlueGreen;
if blue_green {
let primary = resolved_ports
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case("http"))
.or_else(|| resolved_ports.first())
.map(|(name, port)| (name.clone(), *port));
if let Some((name, blue_port)) = primary {
let green_key = format!("{}_green", name.to_ascii_lowercase());
let green_port = match port_overrides.get(&green_key) {
Some(pinned) => *pinned,
None => {
let p = system::port::allocate_port_excluding(&claimed, port_in_use)?;
claimed.insert(p);
p
}
};
resolved_ports.push((format!("{name}_blue"), blue_port));
resolved_ports.push((format!("{name}_green"), green_port));
}
}
let home_dir = service_home(service_name)?;
let quadlet_path = quadlet_dir()?;
let installed_now = list_installed().unwrap_or_default();
let authelia_installed =
find_installed_provider(&installed_now, Capability::OidcProvider).is_some();
let caddy_installed =
find_installed_provider(&installed_now, Capability::ReverseProxy).is_some();
let inbucket_installed =
find_installed_provider(&installed_now, Capability::SmtpRelay).is_some();
let metrics_store =
find_installed_provider(&installed_now, Capability::MetricsStore).map(|s| s.name.clone());
let provides_auth_infra = reg_service
.def
.capabilities
.provides
.iter()
.any(|c| matches!(c, Capability::OidcProvider | Capability::ReverseProxy));
if enable_auth
&& !provides_auth_infra
&& !caddy_installed
&& let Some(authelia) = find_installed_provider(&installed_now, Capability::OidcProvider)
&& let Some(auth_url) = authelia.exposure.url()
{
return Err(Error::AuthRequiresReverseProxy {
service: service_name.to_string(),
auth_url: auth_url.to_string(),
});
}
let auth_bridge = auth_bridge::build(&auth_bridge::AuthBridgeParams {
service_name,
service_provides: ®_service.def.capabilities.provides,
enable_auth,
config: &config,
installed: &installed_now,
service_data: &home_dir,
})?;
let (extra_volumes, extra_env, extra_exec_start_pre, auth_bridge_steps) = match auth_bridge {
Some(b) => (b.volumes, b.env, b.exec_start_pre, b.steps),
None => (Vec::new(), BTreeMap::new(), Vec::new(), Vec::new()),
};
let has_smtp = enable_smtp
&& reg_service.def.integrations.smtp
&& !reg_service.def.mappings.smtp.is_empty()
&& config.smtp.is_some();
let wants_metrics = reg_service
.def
.metrics
.as_ref()
.is_some_and(|m| !m.host_network)
|| capability::def_provides(®_service.def, Capability::MetricsDashboard);
let extra_networks = resolve_extra_networks(
service_name,
enable_auth,
authelia_installed,
caddy_installed,
inbucket_installed,
url.is_some(),
has_smtp,
metrics_store.as_deref(),
wants_metrics,
);
let output = generate::generate_env(generate::GenerateEnvParams {
config: &config,
service_def: ®_service.def,
auth_kind,
host_port,
resolved_ports: &resolved_ports,
env_overrides,
exposure,
extra_env,
pre_built_ctx,
enable_smtp: has_smtp,
enabled_groups,
selected_choices,
})?;
let podman_args: Vec<String> = Vec::new();
let port_names: Vec<String> = resolved_ports.iter().map(|(n, _)| n.clone()).collect();
let active_color = match reg_service.def.service.deploy {
registry::service_def::DeployStrategy::BlueGreen => {
Some(registry::service_def::Color::Blue)
}
registry::service_def::DeployStrategy::Restart => None,
};
let install_metadata = Metadata {
registry: registry_name.to_string(),
url: url.map(str::to_string),
auth: auth_kind.cloned(),
provides: reg_service.def.capabilities.provides.clone(),
backup_enabled: enable_backup,
smtp_enabled: enable_smtp,
enabled_groups: enabled_groups.iter().cloned().collect(),
selected_choices: selected_choices.clone(),
runtime: reg_service.def.service.runtime.clone(),
active_color,
};
if reg_service.def.service.runtime == registry::service_def::Runtime::Native {
let tracked_envs = collect_static_envs(
®_service.def,
&output.ctx,
enabled_groups,
selected_choices,
)?;
let allocated_ports = resolved_ports.clone();
let generated_secrets = collect_generated_secrets(®_service.def, env_overrides);
let native_blue_green =
reg_service.def.service.deploy == registry::service_def::DeployStrategy::BlueGreen;
let mut caddy_steps: Vec<Step> = Vec::new();
let mut native_warnings: Vec<Warning> = Vec::new();
if let Some(u) = url
&& !exposure.is_tailscale()
{
let upstream_port = if native_blue_green {
resolved_ports
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case("http_blue"))
.map(|(_, p)| *p)
} else {
host_port
};
if let Some(p) = upstream_port {
let (route_steps, route_warnings) = caddy_route_steps(
service_name,
u,
"host.containers.internal".to_string(),
p,
host_port,
caddy_installed,
caddy_https_port(&config),
)?;
caddy_steps = route_steps;
native_warnings.extend(route_warnings);
}
}
return build_native_add(NativeAddParams {
service_name,
reg_service: ®_service,
home_dir: &home_dir,
output,
install_metadata: &install_metadata,
registry_name,
url,
tracked_envs,
allocated_ports,
generated_secrets,
excluded_quadlets: excluded_quadlets(®_service.def, selected_choices),
caddy_steps,
warnings: native_warnings,
});
}
let excluded_quadlets = excluded_quadlets(®_service.def, selected_choices);
let bundle =
generate::bundle::process_quadlet_bundle(&generate::bundle::ProcessBundleParams {
service_dir: ®_service.service_dir,
service_name,
extra_networks: &extra_networks,
extra_volumes: &extra_volumes,
podman_args: &podman_args,
extra_exec_start_pre: &extra_exec_start_pre,
port_names: &port_names,
excluded_quadlets: &excluded_quadlets,
})?;
let mut warnings = Vec::new();
if let Some(ref reqs) = reg_service.def.requirements
&& let Some(total) = system::memory::total_ram_mb()
{
if total < reqs.ram.min {
warnings.push(Warning::RamBelowMinimum {
service_name: service_name.to_string(),
min_mb: reqs.ram.min,
available_mb: total,
});
} else if let Some(rec) = reqs.ram.recommended
&& total < rec
{
warnings.push(Warning::RamBelowRecommended {
service_name: service_name.to_string(),
recommended_mb: rec,
available_mb: total,
});
}
}
warnings.extend(port_warnings);
let mut steps = Vec::new();
steps.push(Step::CreateDir(home_dir.clone()));
let env_content = output.env_file.content.clone();
for image in &bundle.images {
steps.push(Step::PullImage {
image: image.clone(),
});
}
let quadlet_files = if blue_green {
deploy::expand_color_quadlets(bundle.quadlet_files, service_name)
} else {
bundle.quadlet_files
};
for file in quadlet_files {
let link = file
.path
.file_name()
.map(|n| quadlet_path.join(n))
.ok_or_else(|| {
Error::Bundle(format!("invalid quadlet path: {}", file.path.display()))
})?;
let target = file.path.clone();
steps.push(Step::WriteFile(file));
steps.push(Step::Symlink { link, target });
}
let metadata_content = toml::to_string_pretty(&install_metadata)?;
steps.push(Step::WriteFile(GeneratedFile {
path: metadata_path(service_name)?,
content: metadata_content,
}));
if mode == PlanMode::Add && exposure.is_tailscale() {
let svc_name = exposure.tailscale_svc_name().ok_or_else(|| {
Error::InvalidServiceRef(format!(
"tailscale exposure for '{service_name}' has a malformed URL — \
expected `https://<service>-<host>.<tailnet>.ts.net/`"
))
})?;
let ts_ports = plan::tailscale_ports(®_service.def.ports, &resolved_ports, host_port);
if !ts_ports.is_empty() {
steps.push(Step::TailscaleSetup);
steps.push(Step::TailscaleEnable {
svc_name,
ports: ts_ports,
});
}
}
for file in bundle.config_files {
steps.push(Step::WriteFile(file));
}
for (src, dst) in bundle.files {
steps.push(Step::CopyFile { src, dst });
}
steps.push(Step::WriteFile(output.env_file));
for dir in &bundle.bind_mount_dirs {
steps.push(Step::CreateDir(dir.clone()));
}
steps.extend(auth_bridge_steps);
if mode == PlanMode::Add
&& let (
Some(registry::service_def::AuthKind::Oidc),
Some(config::schema::AuthCredentials::Authelia { .. }),
) = (auth_kind, config.auth.as_ref())
{
steps.extend(authelia::register_oidc_client(
service_name,
®_service.def,
url,
&output.ctx,
&quadlet_path,
)?);
}
if let Some(url) = url
&& !WellKnownService::Caddy.matches(service_name)
&& !exposure.is_tailscale()
{
let container_port = reg_service
.def
.ports
.first()
.map(|p| p.container_port)
.unwrap_or(80);
let primary_quadlet = reg_service
.service_dir
.join("quadlets")
.join(format!("{service_name}.container"));
let target_host = if blue_green {
deploy::color_unit(service_name, registry::service_def::Color::Blue)
} else {
caddy::primary_container_name(&primary_quadlet, service_name)
};
let (route_steps, route_warnings) = caddy_route_steps(
service_name,
url,
target_host,
container_port,
host_port,
caddy_installed,
caddy_https_port(&config),
)?;
steps.extend(route_steps);
warnings.extend(route_warnings);
}
if mode == PlanMode::Add {
steps.extend(retroactive_network_joins(
service_name,
&quadlet_path,
Some(repo_dir),
));
}
if mode == PlanMode::Add {
if let Some(store) = &metrics_store {
let metrics_host_port = reg_service.def.metrics.as_ref().and_then(|m| {
resolved_ports
.iter()
.find(|(n, _)| n == &m.port)
.map(|(_, p)| *p)
});
if let Some(step) =
metrics_bridge::scrape_target_step(store, ®_service.def, metrics_host_port)?
{
steps.push(step);
}
if capability::def_provides(®_service.def, Capability::MetricsDashboard)
&& let Some(port) = store_container_port(store)
{
steps.push(metrics_bridge::datasource_step(service_name, store, port)?);
}
}
if capability::def_provides(®_service.def, Capability::MetricsStore) {
steps.extend(retroactive_metrics_wiring(
service_name,
®_service.def,
&quadlet_path,
));
}
}
if WellKnownService::Caddy.matches(service_name) {
let snippet_path = caddy::tls_snippet_path()?;
if !snippet_path.exists() {
let mode = acme_mode.cloned().unwrap_or(caddy::AcmeMode::Internal);
steps.push(Step::WriteFile(GeneratedFile {
path: snippet_path,
content: mode.snippet(),
}));
}
}
let manifest_path_for_svc = manifest::manifest_path(service_name)?;
let env_filename = std::ffi::OsStr::new(".env");
let mut manifest_entries: Vec<manifest::ManifestEntry> = Vec::new();
for step in &steps {
if let Step::WriteFile(file) = step {
if file.path == manifest_path_for_svc {
continue;
}
if file.path.file_name() == Some(env_filename) {
continue;
}
manifest_entries.push(manifest::ManifestEntry {
path: file.path.clone(),
sha256: manifest::hash_bytes(file.content.as_bytes()),
});
}
}
let tracked_envs = collect_static_envs(
®_service.def,
&output.ctx,
enabled_groups,
selected_choices,
)?;
let manifest_envs: Vec<manifest::EnvEntry> = tracked_envs
.iter()
.map(|t| manifest::EnvEntry {
key: t.key.clone(),
value: t.value.clone(),
})
.collect();
steps.push(Step::WriteFile(GeneratedFile {
path: manifest_path_for_svc,
content: manifest::format(&manifest_entries, &manifest_envs),
}));
steps.push(Step::DaemonReload);
let start_unit = if blue_green {
deploy::color_unit(service_name, registry::service_def::Color::Blue)
} else {
service_name.to_string()
};
steps.push(Step::StartService { unit: start_unit });
let allocated_ports: Vec<(String, u16)> = resolved_ports.clone();
let mut generated_secrets: Vec<String> = reg_service
.def
.env
.iter()
.filter(|e| !env_overrides.contains_key(&e.name))
.flat_map(|e| generate::extract_secret_refs(&e.value))
.collect();
generated_secrets.sort();
generated_secrets.dedup();
Ok(AddResult {
steps,
warnings,
repo_url: registry_name.to_string(),
allocated_ports,
generated_secrets,
env_content,
url: url.map(|u| u.to_string()),
tracked_envs,
})
}
fn collect_generated_secrets(
def: ®istry::service_def::ServiceDef,
env_overrides: &BTreeMap<String, String>,
) -> Vec<String> {
let mut out: Vec<String> = def
.env
.iter()
.filter(|e| !env_overrides.contains_key(&e.name))
.flat_map(|e| generate::extract_secret_refs(&e.value))
.collect();
out.sort();
out.dedup();
out
}
struct NativeAddParams<'a> {
service_name: &'a str,
reg_service: &'a registry::RegistryService,
home_dir: &'a Path,
output: generate::EnvOutput,
install_metadata: &'a Metadata,
registry_name: &'a str,
url: Option<&'a str>,
tracked_envs: Vec<TrackedEnv>,
allocated_ports: Vec<(String, u16)>,
generated_secrets: Vec<String>,
excluded_quadlets: Vec<String>,
caddy_steps: Vec<Step>,
warnings: Vec<Warning>,
}
fn excluded_quadlets(
def: ®istry::service_def::ServiceDef,
selected_choices: &BTreeMap<String, String>,
) -> Vec<String> {
let mut all_claimed: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
let mut selected: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for choice in &def.choices {
let picked = selected_choices
.get(&choice.name)
.unwrap_or(&choice.default);
for option in &choice.options {
for q in &option.quadlets {
all_claimed.insert(q.clone());
if &option.name == picked {
selected.insert(q.clone());
}
}
}
}
all_claimed.difference(&selected).cloned().collect()
}
fn build_native_add(p: NativeAddParams<'_>) -> Result<AddResult> {
let NativeAddParams {
service_name,
reg_service,
home_dir,
output,
install_metadata,
registry_name,
url,
tracked_envs,
allocated_ports,
generated_secrets,
excluded_quadlets,
caddy_steps,
warnings,
} = p;
let run = reg_service.def.service.run.as_ref().ok_or_else(|| {
Error::Bundle(format!(
"native service '{service_name}' is missing its `run` command"
))
})?;
let build = reg_service.def.service.build.as_ref();
let env_content = output.env_file.content.clone();
let source_dir = reg_service.service_dir.clone();
let mut steps = Vec::new();
steps.push(Step::CreateDir(home_dir.to_path_buf()));
steps.push(Step::CreateDir(home_dir.join("data")));
let blue_green =
reg_service.def.service.deploy == registry::service_def::DeployStrategy::BlueGreen;
steps.push(Step::WriteFile(GeneratedFile {
path: metadata_path(service_name)?,
content: toml::to_string_pretty(install_metadata)?,
}));
steps.push(Step::WriteFile(output.env_file));
let description = reg_service.def.service.description.as_str();
if blue_green {
let primary = allocated_ports
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case("http"))
.or_else(|| allocated_ports.first())
.map(|(n, _)| n.clone())
.ok_or_else(|| {
Error::Bundle(format!(
"blue/green native '{service_name}' has no port to route"
))
})?;
let port_var = format!("SERVICE_PORT_{}", primary.to_uppercase());
let home_str = home_dir.to_string_lossy().into_owned();
for color in [
registry::service_def::Color::Blue,
registry::service_def::Color::Green,
] {
let slot = home_dir.join("colors").join(color.as_str());
let slot_str = slot.to_string_lossy().into_owned();
let port = allocated_ports
.iter()
.find(|(n, _)| *n == format!("{}_{}", primary.to_ascii_lowercase(), color))
.map(|(_, p)| *p)
.ok_or_else(|| {
Error::Bundle(format!(
"blue/green native '{service_name}' missing the {color} port"
))
})?;
steps.push(Step::SyncDir {
src: source_dir.clone(),
dst: slot.clone(),
});
if let Some(command) = build {
steps.push(Step::Build {
dir: slot.clone(),
command: command.clone(),
});
}
let unit_name = format!("{}.service", deploy::color_unit(service_name, color));
let unit_path = home_dir.join(&unit_name);
steps.push(Step::WriteFile(GeneratedFile {
path: unit_path.clone(),
content: deploy::native_color_unit(&deploy::NativeColorUnit {
description,
color,
workdir: &slot_str,
home: &home_str,
port_var: &port_var,
port,
run,
}),
}));
steps.push(Step::Symlink {
link: systemd_user_dir()?.join(&unit_name),
target: unit_path,
});
}
} else {
if let Some(command) = build {
steps.push(Step::Build {
dir: source_dir.clone(),
command: command.clone(),
});
}
let unit_name = format!("{service_name}.service");
let unit_path = home_dir.join(&unit_name);
steps.push(Step::WriteFile(GeneratedFile {
path: unit_path.clone(),
content: native_unit(home_dir, &source_dir, run, description),
}));
steps.push(Step::Symlink {
link: systemd_user_dir()?.join(&unit_name),
target: unit_path,
});
}
let mut quadlet_units: Vec<String> = Vec::new();
if source_dir.join("quadlets").is_dir() {
let port_names: Vec<String> = allocated_ports.iter().map(|(n, _)| n.clone()).collect();
let bundle =
generate::bundle::process_quadlet_bundle(&generate::bundle::ProcessBundleParams {
service_dir: &source_dir,
service_name,
extra_networks: &[],
extra_volumes: &[],
podman_args: &[],
extra_exec_start_pre: &[],
port_names: &port_names,
excluded_quadlets: &excluded_quadlets,
})?;
for image in &bundle.images {
steps.push(Step::PullImage {
image: image.clone(),
});
}
for dir in &bundle.bind_mount_dirs {
steps.push(Step::CreateDir(dir.clone()));
}
let quadlet_path = quadlet_dir()?;
for file in bundle.quadlet_files {
let fname = file
.path
.file_name()
.ok_or_else(|| {
Error::Bundle(format!("invalid quadlet path: {}", file.path.display()))
})?
.to_os_string();
if let Some(stem) = fname.to_string_lossy().strip_suffix(".container") {
quadlet_units.push(stem.to_string());
}
let link = quadlet_path.join(&fname);
let target = file.path.clone();
steps.push(Step::WriteFile(file));
steps.push(Step::Symlink { link, target });
}
}
steps.push(Step::DaemonReload);
for unit in &quadlet_units {
steps.push(Step::StartService { unit: unit.clone() });
}
let app_unit = if blue_green {
deploy::color_unit(service_name, registry::service_def::Color::Blue)
} else {
service_name.to_string()
};
steps.push(Step::StartService { unit: app_unit });
steps.extend(caddy_steps);
Ok(AddResult {
steps,
warnings,
repo_url: registry_name.to_string(),
allocated_ports,
generated_secrets,
env_content,
url: url.map(|u| u.to_string()),
tracked_envs,
})
}
fn native_unit(home_dir: &Path, source_dir: &Path, run: &str, description: &str) -> String {
let home = home_dir.display();
let source = source_dir.display();
format!(
"[Unit]\n\
Description={description}\n\
After=network.target\n\
\n\
[Service]\n\
Type=simple\n\
WorkingDirectory={source}\n\
EnvironmentFile={home}/.env\n\
Environment=SERVICE_HOME={home}\n\
Environment=PATH=%h/.local/bin:%h/.cargo/bin:%h/.bun/bin:%h/.deno/bin:%h/go/bin:/usr/local/bin:/usr/bin:/bin\n\
ExecStart=/bin/sh -c 'exec {run}'\n\
Restart=always\n\
RestartSec=5\n\
\n\
[Install]\n\
WantedBy=default.target\n",
)
}
pub fn quadlet_belongs_to(filename: &str, service_name: &str, all_service_names: &[&str]) -> bool {
if !filename.starts_with(service_name) {
return false;
}
let rest = &filename[service_name.len()..];
if rest.starts_with('.') {
return true;
}
if !rest.starts_with('-') {
return false;
}
!all_service_names.iter().any(|&other| {
other.len() > service_name.len()
&& other.starts_with(service_name)
&& filename.starts_with(other)
&& filename[other.len()..].starts_with(['.', '-'])
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RemoveMode {
#[default]
Preserve,
Purge,
}
pub fn remove_service(service_name: &str, mode: RemoveMode) -> Result<RemoveResult> {
let installed_owned = build_installed_from_metadata(service_name)
.ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
let installed = &installed_owned;
if let Ok(Some(meta)) = metadata::load_metadata(service_name)
&& meta.runtime == registry::service_def::Runtime::Native
{
let url = installed.exposure.url().map(|s| s.to_string());
return remove_native_service(service_name, mode, url);
}
let quadlet_path = quadlet_dir()?;
let mut steps = Vec::new();
let mut volume_names = Vec::new();
let mut networks: Vec<String> = Vec::new();
let mut has_named_volumes = false;
let name_pool = scan_managed_services().unwrap_or_default();
let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
if let Some(svc_name) = installed.exposure.tailscale_svc_name() {
steps.push(Step::TailscaleDisable { svc_name });
}
if quadlet_path.is_dir()
&& let Ok(entries) = std::fs::read_dir(&quadlet_path)
{
for entry in entries.flatten() {
let file_name = entry.file_name();
let name = file_name.to_string_lossy();
if !quadlet_belongs_to(&name, service_name, &all_names) {
continue;
}
if name.ends_with(".container") {
let unit = name.trim_end_matches(".container").to_string();
steps.push(Step::StopService { unit });
}
if name.ends_with(".network") {
let net = name.trim_end_matches(".network").to_string();
steps.push(Step::StopService {
unit: format!("{net}-network"),
});
networks.push(net);
}
if name.ends_with(".volume") {
has_named_volumes = true;
if matches!(mode, RemoveMode::Purge) {
let vol = name.trim_end_matches(".volume").to_string();
volume_names.push(format!("systemd-{vol}"));
}
}
steps.push(Step::RemoveFile(entry.path()));
}
}
let had_caddy_route = matches!(
installed.exposure,
Exposure::Internal { .. } | Exposure::Public { .. }
);
if !WellKnownService::Caddy.matches(service_name) && had_caddy_route {
let caddyfile_path = caddy::caddyfile_path()?;
if caddyfile_path.exists() {
let existing =
std::fs::read_to_string(&caddyfile_path).map_err(|source| Error::FileRead {
path: caddyfile_path.clone(),
source,
})?;
let updated = caddy::remove_route(&existing, service_name);
if updated != existing {
steps.push(Step::WriteFile(GeneratedFile {
path: caddyfile_path,
content: updated.clone(),
}));
if !updated.trim().is_empty() {
steps.push(Step::ReloadCaddy);
}
}
}
}
if !WellKnownService::Authelia.matches(service_name)
&& matches!(
installed.auth_kind,
Some(registry::service_def::AuthKind::Oidc)
)
{
steps.extend(authelia::unregister_oidc_client(service_name)?);
}
let installed_all = list_installed().unwrap_or_default();
for store in installed_all
.iter()
.filter(|s| installed_provides(s, Capability::MetricsStore))
{
if store.name != service_name
&& let Ok(target) = metrics_bridge::target_file_path(&store.name, service_name)
&& target.exists()
{
steps.push(Step::RemoveFile(target));
}
}
if installed.provides.contains(&Capability::MetricsStore) {
for dash in installed_all
.iter()
.filter(|s| installed_provides(s, Capability::MetricsDashboard))
{
if dash.name == service_name {
continue;
}
if let Ok(ds) = metrics_bridge::datasource_file_path(&dash.name, service_name)
&& ds.exists()
{
steps.push(Step::RemoveFile(ds));
steps.push(Step::RestartService {
unit: dash.name.clone(),
});
}
}
}
steps.push(Step::DaemonReload);
for net in networks {
steps.push(Step::RemoveNetwork { name: net });
}
match mode {
RemoveMode::Purge => {
for vol_name in volume_names {
steps.push(Step::RemoveVolume { name: vol_name });
}
steps.push(Step::RemoveDir(service_home(service_name)?));
}
RemoveMode::Preserve => {
let home = service_home(service_name)?;
let (data, ephemeral) = crate::data::classify::classify_home_dir(&home)?;
for path in ephemeral {
match std::fs::metadata(&path) {
Ok(m) if m.is_dir() => steps.push(Step::RemoveDir(path)),
Ok(_) => steps.push(Step::RemoveFile(path)),
Err(_) => steps.push(Step::RemoveFile(path)),
}
}
if data.is_empty() && !has_named_volumes && home.exists() {
steps.push(Step::RemoveDir(home));
}
}
}
let url = installed.exposure.url().map(|s| s.to_string());
Ok(RemoveResult {
steps,
service_name: service_name.to_string(),
url,
})
}
fn remove_native_service(
service_name: &str,
mode: RemoveMode,
url: Option<String>,
) -> Result<RemoveResult> {
let home = service_home(service_name)?;
let unit_dir = systemd_user_dir()?;
let unit_names: Vec<String> = [
format!("{service_name}.service"),
format!("{service_name}-blue.service"),
format!("{service_name}-green.service"),
]
.into_iter()
.filter(|u| unit_dir.join(u).exists())
.collect();
let mut steps = Vec::new();
let mut aux_container_files: Vec<String> = Vec::new();
if let Ok(qdir) = quadlet_dir() {
let names = scan_managed_services().unwrap_or_default();
let all: Vec<&str> = names.iter().map(|s| s.as_str()).collect();
if let Ok(entries) = std::fs::read_dir(&qdir) {
for entry in entries.flatten() {
let fname = entry.file_name().to_string_lossy().into_owned();
if let Some(stem) = fname.strip_suffix(".container")
&& quadlet_belongs_to(&fname, service_name, &all)
{
steps.push(Step::StopService {
unit: stem.to_string(),
});
steps.push(Step::RemoveFile(qdir.join(&fname)));
aux_container_files.push(fname);
}
}
}
}
for unit_name in &unit_names {
steps.push(Step::StopService {
unit: unit_name.trim_end_matches(".service").to_string(),
});
steps.push(Step::RemoveFile(unit_dir.join(unit_name)));
}
steps.push(Step::DaemonReload);
match mode {
RemoveMode::Purge => steps.push(Step::RemoveDir(home)),
RemoveMode::Preserve => {
let mut ephemeral: Vec<String> = vec!["bin".into(), ".env".into(), "colors".into()];
ephemeral.extend(unit_names.iter().cloned());
for child in &ephemeral {
let p = home.join(child);
match std::fs::metadata(&p) {
Ok(m) if m.is_dir() => steps.push(Step::RemoveDir(p)),
Ok(_) => steps.push(Step::RemoveFile(p)),
Err(_) => {} }
}
for f in &aux_container_files {
steps.push(Step::RemoveFile(home.join(f)));
}
}
}
Ok(RemoveResult {
steps,
service_name: service_name.to_string(),
url,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Lifecycle {
Start,
Stop,
}
pub fn lifecycle_steps(service_name: &str, action: Lifecycle) -> Result<Vec<Step>> {
build_installed_from_metadata(service_name)
.ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
if matches!(
metadata::load_metadata(service_name),
Ok(Some(m)) if m.runtime == registry::service_def::Runtime::Native
) {
let unit = service_name.to_string();
return Ok(vec![match action {
Lifecycle::Start => Step::StartService { unit },
Lifecycle::Stop => Step::StopService { unit },
}]);
}
let mut units = service_container_units(service_name)?;
match action {
Lifecycle::Stop => units.sort_by_key(|u| u != service_name),
Lifecycle::Start => units.sort_by_key(|u| u == service_name),
}
Ok(units
.into_iter()
.map(|unit| match action {
Lifecycle::Start => Step::StartService { unit },
Lifecycle::Stop => Step::StopService { unit },
})
.collect())
}
fn service_container_units(service_name: &str) -> Result<Vec<String>> {
let quadlet_path = quadlet_dir()?;
let name_pool = scan_managed_services().unwrap_or_default();
let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
let mut units = Vec::new();
if quadlet_path.is_dir()
&& let Ok(entries) = std::fs::read_dir(&quadlet_path)
{
for entry in entries.flatten() {
let file_name = entry.file_name();
let name = file_name.to_string_lossy();
if !quadlet_belongs_to(&name, service_name, &all_names) {
continue;
}
if name.ends_with(".container") {
units.push(name.trim_end_matches(".container").to_string());
}
}
}
Ok(units)
}
pub struct RecordPendingParams<'a> {
pub service_name: &'a str,
pub auth_kind: Option<registry::service_def::AuthKind>,
pub registry_name: &'a str,
pub allocated_ports: &'a [(String, u16)],
pub repo_dir: &'a Path,
pub exposure: &'a Exposure,
}
pub fn record_pending(params: RecordPendingParams<'_>) -> Result<()> {
let paths = ConfigPaths::resolve()?;
paths.ensure_dirs()?;
let mut config = config::load_or_default(&paths.config_file)?;
if WellKnownService::Authelia.matches(params.service_name) {
config.auth = Some(authelia::auth_config(
params.allocated_ports,
params.exposure.url(),
)?);
config::save_config(&paths.config_file, &config)?;
}
Ok(())
}
pub fn finalize_remove(service_name: &str) -> Result<()> {
let paths = ConfigPaths::resolve()?;
let mut config = config::load_or_default(&paths.config_file)?;
if WellKnownService::Authelia.matches(service_name)
&& let Some(auth) = &config.auth
&& auth.provider_name() == "authelia"
{
config.auth = None;
config::save_config(&paths.config_file, &config)?;
}
Ok(())
}
const SENSITIVE_TEMPLATE_REFS: &[&str] = &[
"{{secret.",
"{{auth.client_id",
"{{auth.client_secret",
"{{smtp.username",
"{{smtp.password",
];
fn is_static_template(value: &str) -> bool {
!SENSITIVE_TEMPLATE_REFS.iter().any(|s| value.contains(s))
}
fn collect_static_envs(
service_def: ®istry::service_def::ServiceDef,
ctx: &BTreeMap<String, String>,
enabled_groups: &std::collections::BTreeSet<String>,
selected_choices: &BTreeMap<String, String>,
) -> Result<Vec<plan::TrackedEnv>> {
use registry::service_def::EnvKind;
let mut out: Vec<plan::TrackedEnv> = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let push = |name: &str,
value_template: &str,
kind: EnvKind,
prompt: Option<String>,
out: &mut Vec<plan::TrackedEnv>,
seen: &mut std::collections::HashSet<String>|
-> Result<()> {
if !is_static_template(value_template) {
return Ok(());
}
if !seen.insert(name.to_string()) {
return Ok(());
}
let value = generate::template::render(value_template, ctx)?;
out.push(plan::TrackedEnv {
key: name.to_string(),
value,
kind,
prompt,
});
Ok(())
};
for env in &service_def.env {
push(
&env.name,
&env.value,
env.kind.clone(),
env.prompt.clone(),
&mut out,
&mut seen,
)?;
}
for group in &service_def.env_groups {
if !enabled_groups.contains(&group.name) {
continue;
}
for env in &group.env {
push(
&env.name,
&env.value,
env.kind.clone(),
env.prompt.clone(),
&mut out,
&mut seen,
)?;
}
}
for choice in &service_def.choices {
let selected = selected_choices
.get(&choice.name)
.unwrap_or(&choice.default);
let Some(option) = choice.options.iter().find(|o| &o.name == selected) else {
continue;
};
for env in &option.env {
push(
&env.name,
&env.value,
env.kind.clone(),
env.prompt.clone(),
&mut out,
&mut seen,
)?;
}
}
if service_def.integrations.smtp && ctx.contains_key("smtp.host") {
for (env_name, value_template) in &service_def.mappings.smtp {
push(
env_name,
value_template,
EnvKind::Default,
None,
&mut out,
&mut seen,
)?;
}
}
if ctx.contains_key("auth.client_id") {
for (env_name, value_template) in &service_def.mappings.auth {
push(
env_name,
value_template,
EnvKind::Default,
None,
&mut out,
&mut seen,
)?;
}
}
Ok(out)
}
pub fn orphan_purge_steps(svc: &data::ServiceData) -> Vec<Step> {
let mut steps = Vec::new();
let mut had_quadlet = false;
let mut networks: Vec<String> = Vec::new();
if let Ok(qdir) = quadlet_dir()
&& qdir.is_dir()
&& let Ok(entries) = std::fs::read_dir(&qdir)
{
let name_pool = scan_managed_services().unwrap_or_default();
let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
for entry in entries.flatten() {
let file_name = entry.file_name();
let name = file_name.to_string_lossy();
if !quadlet_belongs_to(&name, &svc.service, &all_names) {
continue;
}
if name.ends_with(".container") {
let unit = name.trim_end_matches(".container").to_string();
steps.push(Step::StopService { unit });
} else if name.ends_with(".network") {
let net = name.trim_end_matches(".network").to_string();
steps.push(Step::StopService {
unit: format!("{net}-network"),
});
networks.push(net);
} else if name.ends_with(".volume") {
let unit = format!("{}-volume", name.trim_end_matches(".volume"));
steps.push(Step::StopService { unit });
}
steps.push(Step::RemoveFile(entry.path()));
had_quadlet = true;
}
}
if had_quadlet {
steps.push(Step::DaemonReload);
}
for net in networks {
steps.push(Step::RemoveNetwork { name: net });
}
for path in &svc.data_paths {
if path.is_dir() {
steps.push(Step::RemoveDir(path.clone()));
} else {
steps.push(Step::RemoveFile(path.clone()));
}
}
if svc.home_dir.exists() {
steps.push(Step::RemoveDir(svc.home_dir.clone()));
}
for v in &svc.volumes {
steps.push(Step::RemoveVolume {
name: v.name.clone(),
});
}
steps
}
pub fn reset() -> Result<ResetResult> {
let mut steps = Vec::new();
let managed_names = scan_managed_services().unwrap_or_default();
for svc in list_installed().unwrap_or_default() {
if let Some(svc_name) = svc.exposure.tailscale_svc_name() {
steps.push(Step::TailscaleDisable { svc_name });
}
}
let quadlet_path = quadlet_dir()?;
let all_names: Vec<&str> = managed_names.iter().map(|s| s.as_str()).collect();
let mut networks: Vec<String> = Vec::new();
if quadlet_path.is_dir()
&& let Ok(entries) = std::fs::read_dir(&quadlet_path)
{
for entry in entries.flatten() {
let file_name = entry.file_name();
let name = file_name.to_string_lossy();
let is_ryra_file = managed_names
.iter()
.any(|svc| quadlet_belongs_to(&name, svc, &all_names));
if !is_ryra_file {
continue;
}
if name.ends_with(".container") {
let unit = name.trim_end_matches(".container").to_string();
steps.push(Step::StopService { unit });
}
if name.ends_with(".network") {
let net = name.trim_end_matches(".network").to_string();
steps.push(Step::StopService {
unit: format!("{net}-network"),
});
networks.push(net);
}
if name.ends_with(".volume") {
let vol = name.trim_end_matches(".volume").to_string();
steps.push(Step::StopService {
unit: format!("{vol}-volume"),
});
}
steps.push(Step::RemoveFile(entry.path()));
}
}
let user_unit_dir = systemd_user_dir()?;
if let Ok(root) = service_data_root()
&& let Ok(entries) = std::fs::read_dir(&root)
{
for entry in entries.flatten() {
let Some(name) = entry.file_name().to_str().map(str::to_string) else {
continue;
};
if matches!(
metadata::load_metadata(&name),
Ok(Some(m)) if m.runtime == registry::service_def::Runtime::Native
) {
steps.push(Step::StopService { unit: name.clone() });
steps.push(Step::RemoveFile(
user_unit_dir.join(format!("{name}.service")),
));
}
}
}
steps.push(Step::DaemonReload);
for net in networks {
steps.push(Step::RemoveNetwork { name: net });
}
let mut seen_volumes = std::collections::BTreeSet::new();
for svc in data::enumerate_all().unwrap_or_default() {
for vol in svc.volumes {
if seen_volumes.insert(vol.name.clone()) {
steps.push(Step::RemoveVolume { name: vol.name });
}
}
}
let data_root = service_data_root()?;
if data_root.exists() {
steps.push(Step::RemoveDir(data_root));
}
Ok(ResetResult { steps })
}
pub fn finalize_reset() -> Result<()> {
let paths = ConfigPaths::resolve()?;
if paths.config_dir.exists() {
std::fs::remove_dir_all(&paths.config_dir).map_err(|source| Error::FileWrite {
path: paths.config_dir,
source,
})?;
}
Ok(())
}
pub fn status() -> config::status::RyraStatus {
let paths = match ConfigPaths::resolve() {
Ok(p) => p,
Err(_) => return config::status::RyraStatus::NotInitialized,
};
let has_quadlets = scan_managed_services()
.map(|n| !n.is_empty())
.unwrap_or(false);
let config = match config::load_config(&paths.config_file) {
Ok(c) => c,
Err(Error::ConfigNotFound(_)) if has_quadlets => config::schema::Config::default(),
Err(Error::ConfigNotFound(_)) => return config::status::RyraStatus::NotInitialized,
Err(e) => return config::status::RyraStatus::Error(e.to_string()),
};
config::status::RyraStatus::Initialized(config::status::StatusInfo::from_config(
paths.config_file,
&config,
))
}
pub fn is_service_installed(name: &str) -> bool {
let Ok(Some(meta)) = metadata::load_metadata(name) else {
return false;
};
match meta.runtime {
registry::service_def::Runtime::Native => systemd_user_dir()
.map(|d| {
d.join(format!("{name}.service")).exists()
|| d.join(format!("{name}-blue.service")).exists()
|| d.join(format!("{name}-green.service")).exists()
})
.unwrap_or(false),
registry::service_def::Runtime::Podman => scan_managed_services()
.map(|names| names.iter().any(|n| n == name))
.unwrap_or(false),
}
}
pub fn scan_managed_services() -> Result<Vec<String>> {
let dir = match quadlet_dir() {
Ok(d) => d,
Err(_) => return Ok(Vec::new()),
};
let entries = match std::fs::read_dir(&dir) {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(source) => return Err(Error::FileRead { path: dir, source }),
};
let mut names: Vec<String> = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("container") {
continue;
}
let Ok(content) = std::fs::read_to_string(&path) else {
continue;
};
for line in content.lines().take(16) {
if let Some(rest) = line.trim().strip_prefix("# Service-Source: registry/")
&& !rest.is_empty()
&& !names.iter().any(|n| n == rest)
{
names.push(rest.to_string());
break;
}
}
}
names.sort();
Ok(names)
}
fn build_installed_from_metadata(service_name: &str) -> Option<InstalledService> {
let meta = load_metadata(service_name).ok().flatten()?;
let exposure = match meta.url.as_deref() {
None => Exposure::Loopback,
Some(u) => Exposure::from_url(u),
};
let auth_kind = meta.auth.clone();
let ports = service_home(service_name)
.ok()
.and_then(|home| std::fs::read_to_string(home.join(".env")).ok())
.map(|env| {
env.lines()
.filter_map(|l| {
let l = l.trim();
if l.is_empty() || l.starts_with('#') {
return None;
}
let (key, val) = l.split_once('=')?;
let name = key.strip_prefix("SERVICE_PORT_")?.to_lowercase();
let port = val
.trim_matches(|c: char| c == '"' || c == '\'')
.parse::<u16>()
.ok()?;
Some((name, port))
})
.collect::<std::collections::BTreeMap<String, u16>>()
})
.unwrap_or_default();
Some(InstalledService {
name: service_name.to_string(),
version: "0.1.0".to_string(),
repo: meta.registry,
ports,
auth_kind,
exposure,
provides: meta.provides,
installed: true,
})
}
pub fn list_installed() -> Result<Vec<InstalledService>> {
let mut names: std::collections::BTreeSet<String> = scan_managed_services()
.unwrap_or_default()
.into_iter()
.collect();
if let Ok(root) = service_data_root()
&& let Ok(entries) = std::fs::read_dir(&root)
{
for entry in entries.flatten() {
if let Some(name) = entry.file_name().to_str()
&& !names.contains(name)
&& is_service_installed(name)
{
names.insert(name.to_string());
}
}
}
let out: Vec<InstalledService> = names
.iter()
.filter_map(|n| build_installed_from_metadata(n))
.collect();
Ok(out)
}
pub fn search_services(repo_dir: &Path, query: Option<&str>) -> Result<Vec<SearchResult>> {
let available = registry::list_available(repo_dir)?;
let results = available
.into_iter()
.filter(|reg_svc| match query {
None => true,
Some(q) => {
let q = q.to_lowercase();
reg_svc.def.service.name.to_lowercase().contains(&q)
|| reg_svc.def.service.description.to_lowercase().contains(&q)
}
})
.map(|reg_svc| {
let name = ®_svc.def.service.name;
let installed = is_service_installed(name);
let mut supports = Vec::new();
for kind in ®_svc.def.integrations.auth {
supports.push(kind.to_string());
}
if reg_svc.def.integrations.smtp {
supports.push("smtp".to_string());
}
SearchResult {
name: name.clone(),
description: reg_svc.def.service.description,
installed,
supports,
}
})
.collect();
Ok(results)
}
pub struct SearchResult {
pub name: String,
pub description: String,
pub installed: bool,
pub supports: Vec<String>,
}
pub async fn service_tests(service_name: &str) -> Result<ServiceTestInfo> {
let installed = build_installed_from_metadata(service_name)
.ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
let service_ref = service_ref_from_installed(&installed);
let repo_dir = resolve_registry_dir(&service_ref).await?;
let test_toml_path = repo_dir.join(service_name).join("test.toml");
let env_file = service_home(service_name)?.join(".env");
if !test_toml_path.exists() {
return Ok(ServiceTestInfo {
service_name: service_name.to_string(),
registry_name: service_ref.registry_name().to_string(),
tests: vec![],
env_file,
});
}
let content = std::fs::read_to_string(&test_toml_path).map_err(|source| Error::FileRead {
path: test_toml_path.clone(),
source,
})?;
#[derive(serde::Deserialize)]
struct TestFile {
#[serde(default)]
tests: Vec<registry::test_def::TestDef>,
}
let parsed: TestFile = toml::from_str(&content).map_err(|source| Error::TomlParse {
path: test_toml_path,
source,
})?;
Ok(ServiceTestInfo {
service_name: service_name.to_string(),
registry_name: service_ref.registry_name().to_string(),
tests: parsed.tests,
env_file,
})
}
pub struct ServiceTestInfo {
pub service_name: String,
pub registry_name: String,
pub tests: Vec<registry::test_def::TestDef>,
pub env_file: PathBuf,
}
pub fn service_info(repo_dir: &Path, service_name: &str) -> Result<ServiceDetail> {
let reg_service = registry::find_service(repo_dir, service_name)?;
let def = ®_service.def;
Ok(ServiceDetail {
name: def.service.name.clone(),
description: def.service.description.clone(),
url: def.service.url.clone(),
ports: def
.ports
.iter()
.map(|p| (p.container_port, p.protocol.clone(), p.name.clone()))
.collect(),
env_vars: def
.env
.iter()
.map(|e| (e.name.clone(), e.prompt.clone()))
.collect(),
})
}
pub struct ServiceDetail {
pub name: String,
pub description: String,
pub url: Option<String>,
pub ports: Vec<(u16, registry::service_def::PortProtocol, String)>,
pub env_vars: Vec<(String, Option<String>)>,
}
#[cfg(test)]
mod tests {
use super::*;
fn write_demo_registry(tmp: &std::path::Path, deploy_line: &str) {
let svc_dir = tmp.join("demo");
std::fs::create_dir_all(svc_dir.join("quadlets")).unwrap();
std::fs::write(
svc_dir.join("service.toml"),
format!(
"[service]\n\
name = \"demo\"\n\
description = \"demo\"\n\
runtime = \"podman\"\n\
{deploy_line}\n\
\n\
[[ports]]\n\
name = \"http\"\n\
container_port = 8080\n"
),
)
.unwrap();
std::fs::write(
svc_dir.join("quadlets").join("demo.container"),
"[Container]\n\
Image=docker.io/traefik/whoami:latest\n\
ContainerName=demo\n\
PublishPort=${SERVICE_PORT_HTTP}:8080\n\
EnvironmentFile=%h/.local/share/services/demo/.env\n\
\n\
[Service]\n\
EnvironmentFile=%h/.local/share/services/demo/.env\n\
\n\
[Install]\n\
WantedBy=default.target\n",
)
.unwrap();
}
fn write_native_registry(tmp: &std::path::Path) {
let svc_dir = tmp.join("napp");
std::fs::create_dir_all(&svc_dir).unwrap();
std::fs::write(
svc_dir.join("service.toml"),
"[service]\n\
name = \"napp\"\n\
description = \"native demo\"\n\
runtime = \"native\"\n\
run = \"python -m app\"\n\
build = \"pip install -r requirements.txt\"\n\
deploy = \"blue-green\"\n\
health_check = \"/healthz\"\n\
\n\
[[ports]]\n\
name = \"http\"\n\
container_port = 8080\n",
)
.unwrap();
std::fs::write(svc_dir.join("app.py"), "print('hi')\n").unwrap();
}
fn plan_demo(tmp: &std::path::Path) -> AddResult {
plan_service(tmp, "demo")
}
fn plan_service(tmp: &std::path::Path, name: &'static str) -> AddResult {
plan_service_exposed(tmp, name, exposure::Exposure::Loopback)
}
fn plan_service_exposed(
tmp: &std::path::Path,
name: &'static str,
exposure: exposure::Exposure,
) -> AddResult {
let empty_map = std::collections::BTreeMap::new();
let empty_ports: std::collections::BTreeMap<String, u16> =
std::collections::BTreeMap::new();
let empty_set = std::collections::BTreeSet::new();
let port_in_use = |_p: u16| false;
add_service(AddServiceParams {
service_name: name,
exposure: &exposure,
auth: AuthChoice::None,
enable_smtp: false,
enable_backup: false,
env_overrides: &empty_map,
enabled_groups: &empty_set,
selected_choices: &empty_map,
registry_name: "test",
repo_dir: tmp,
pre_built_ctx: None,
port_in_use: &port_in_use,
acme_mode: None,
mode: PlanMode::Add,
port_overrides: &empty_ports,
})
.expect("plan add")
}
#[test]
fn blue_green_podman_add_emits_two_slots_and_starts_blue() {
let tmp = tempfile::tempdir().unwrap();
write_demo_registry(
tmp.path(),
"deploy = \"blue-green\"\nhealth_check = \"/healthz\"",
);
let result = plan_demo(tmp.path());
let written: Vec<String> = result
.steps
.iter()
.filter_map(|s| match s {
Step::WriteFile(f) => f
.path
.file_name()
.and_then(|n| n.to_str())
.map(String::from),
_ => None,
})
.collect();
assert!(
written.iter().any(|n| n == "demo-blue.container"),
"got {written:?}"
);
assert!(
written.iter().any(|n| n == "demo-green.container"),
"got {written:?}"
);
assert!(
!written.iter().any(|n| n == "demo.container"),
"bare slot leaked: {written:?}"
);
let blue = result
.steps
.iter()
.find_map(|s| match s {
Step::WriteFile(f) if f.path.ends_with("demo-blue.container") => Some(&f.content),
_ => None,
})
.unwrap();
assert!(blue.contains("ContainerName=demo-blue"));
assert!(blue.contains("${SERVICE_PORT_HTTP_BLUE}"));
let started: Vec<&str> = result
.steps
.iter()
.filter_map(|s| match s {
Step::StartService { unit } => Some(unit.as_str()),
_ => None,
})
.collect();
assert!(started.contains(&"demo-blue"), "started: {started:?}");
assert!(!started.contains(&"demo"), "bare unit started: {started:?}");
let env = result
.steps
.iter()
.find_map(|s| match s {
Step::WriteFile(f) if f.path.file_name() == Some(std::ffi::OsStr::new(".env")) => {
Some(&f.content)
}
_ => None,
})
.unwrap();
assert!(env.contains("SERVICE_PORT_HTTP_BLUE="), "env: {env}");
assert!(env.contains("SERVICE_PORT_HTTP_GREEN="), "env: {env}");
}
#[test]
fn blue_green_native_add_syncs_builds_and_starts_blue() {
let tmp = tempfile::tempdir().unwrap();
write_native_registry(tmp.path());
let result = plan_service(tmp.path(), "napp");
let syncs: Vec<String> = result
.steps
.iter()
.filter_map(|s| match s {
Step::SyncDir { dst, .. } => Some(dst.to_string_lossy().into_owned()),
_ => None,
})
.collect();
assert!(
syncs.iter().any(|d| d.ends_with("colors/blue")),
"syncs: {syncs:?}"
);
assert!(
syncs.iter().any(|d| d.ends_with("colors/green")),
"syncs: {syncs:?}"
);
let builds: Vec<String> = result
.steps
.iter()
.filter_map(|s| match s {
Step::Build { dir, .. } => Some(dir.to_string_lossy().into_owned()),
_ => None,
})
.collect();
assert!(
builds.iter().any(|d| d.ends_with("colors/blue")),
"builds: {builds:?}"
);
assert!(
builds.iter().any(|d| d.ends_with("colors/green")),
"builds: {builds:?}"
);
let green_unit = result
.steps
.iter()
.find_map(|s| match s {
Step::WriteFile(f) if f.path.ends_with("napp-green.service") => Some(&f.content),
_ => None,
})
.expect("green unit");
assert!(green_unit.contains("WorkingDirectory="));
assert!(green_unit.contains("colors/green"));
assert!(green_unit.contains("Environment=SERVICE_PORT_HTTP="));
assert!(green_unit.contains("ExecStart=/bin/sh -c 'exec python -m app'"));
let started: Vec<&str> = result
.steps
.iter()
.filter_map(|s| match s {
Step::StartService { unit } => Some(unit.as_str()),
_ => None,
})
.collect();
assert_eq!(started, vec!["napp-blue"], "started: {started:?}");
let env = result
.steps
.iter()
.find_map(|s| match s {
Step::WriteFile(f) if f.path.file_name() == Some(std::ffi::OsStr::new(".env")) => {
Some(&f.content)
}
_ => None,
})
.unwrap();
assert!(env.contains("SERVICE_PORT_HTTP_BLUE="));
assert!(env.contains("SERVICE_PORT_HTTP_GREEN="));
}
#[test]
fn blue_green_native_add_with_url_warns_when_no_caddy() {
let tmp = tempfile::tempdir().unwrap();
write_native_registry(tmp.path());
let result = plan_service_exposed(
tmp.path(),
"napp",
exposure::Exposure::Public {
url: "https://napp.example.com".into(),
},
);
assert!(
result
.warnings
.iter()
.any(|w| matches!(w, Warning::UrlWithoutReverseProxy { .. })),
"native + url + no caddy should warn UrlWithoutReverseProxy"
);
}
#[test]
fn restart_podman_add_is_unchanged() {
let tmp = tempfile::tempdir().unwrap();
write_demo_registry(tmp.path(), "");
let result = plan_demo(tmp.path());
let written: Vec<String> = result
.steps
.iter()
.filter_map(|s| match s {
Step::WriteFile(f) => f
.path
.file_name()
.and_then(|n| n.to_str())
.map(String::from),
_ => None,
})
.collect();
assert!(
written.iter().any(|n| n == "demo.container"),
"got {written:?}"
);
assert!(
!written.iter().any(|n| n.contains("-blue")),
"got {written:?}"
);
let started: Vec<&str> = result
.steps
.iter()
.filter_map(|s| match s {
Step::StartService { unit } => Some(unit.as_str()),
_ => None,
})
.collect();
assert!(started.contains(&"demo"));
}
#[test]
fn static_template_filter_excludes_secrets_and_credentials() {
assert!(is_static_template("3306"));
assert!(is_static_template("mariadb"));
assert!(is_static_template("{{service.port}}"));
assert!(is_static_template("{{service.url}}"));
assert!(is_static_template("{{auth.url}}"));
assert!(is_static_template("{{auth.issuer}}"));
assert!(is_static_template("{{auth.provider}}"));
assert!(is_static_template("{{auth.internal_url}}"));
assert!(is_static_template("{{smtp.host}}"));
assert!(is_static_template("{{smtp.port}}"));
assert!(is_static_template("{{smtp.from}}"));
assert!(is_static_template("{{service.url}}/oauth/callback"));
assert!(!is_static_template("{{secret.admin_password}}"));
assert!(!is_static_template("{{secret.jwt_key}}"));
assert!(!is_static_template("{{auth.client_id}}"));
assert!(!is_static_template("{{auth.client_secret}}"));
assert!(!is_static_template("{{smtp.username}}"));
assert!(!is_static_template("{{smtp.password}}"));
assert!(!is_static_template(
"redis://:{{secret.redis_pw}}@host:6379"
));
}
#[test]
fn tailscale_url_matches() {
assert!(is_tailscale_url("http://debian.cobbler-tuna.ts.net"));
assert!(is_tailscale_url("http://debian.cobbler-tuna.ts.net:10001/"));
assert!(is_tailscale_url("https://foo.example-net.ts.net"));
assert!(is_tailscale_url("http://HOST.COBBLER-TUNA.TS.NET"));
}
#[test]
fn tailscale_url_rejects() {
assert!(!is_tailscale_url("https://nextcloud.internal:8443"));
assert!(!is_tailscale_url("https://example.com"));
assert!(!is_tailscale_url("http://127.0.0.1:10001"));
assert!(!is_tailscale_url("https://ts.net"));
assert!(!is_tailscale_url("https://evil-ts.net.example.com"));
assert!(!is_tailscale_url("not a url"));
}
#[test]
fn public_url_accepts_public_domains() {
assert!(is_public_url("https://seafile.ryra.no"));
assert!(is_public_url("https://example.com"));
assert!(is_public_url("https://docs.ryra.no:8443"));
}
#[test]
fn public_url_rejects_lan_and_tailnet() {
assert!(!is_public_url("https://nextcloud.internal:8443"));
assert!(!is_public_url("https://service.localhost"));
assert!(!is_public_url("https://something.local"));
assert!(!is_public_url("https://localhost:8080"));
assert!(!is_public_url("https://debian.cobbler-tuna.ts.net"));
assert!(!is_public_url("http://127.0.0.1:10001"));
assert!(!is_public_url("http://192.168.1.10"));
assert!(!is_public_url("http://[::1]"));
assert!(!is_public_url("not a url"));
}
#[test]
fn networks_empty_when_no_auth() {
let nets = resolve_extra_networks(
"whoami", false, false, false, false, false, false, None, false,
);
assert!(nets.is_empty());
}
#[test]
fn networks_empty_when_auth_but_no_authelia() {
let nets = resolve_extra_networks(
"forgejo", true, false, false, false, false, false, None, false,
);
assert!(nets.is_empty());
}
#[test]
fn networks_authelia_when_auth_enabled() {
let nets = resolve_extra_networks(
"forgejo", true, true, false, false, false, false, None, false,
);
assert_eq!(nets, vec!["authelia"]);
}
#[test]
fn networks_auth_with_caddy_includes_both() {
let nets = resolve_extra_networks(
"forgejo", true, true, true, false, false, false, None, false,
);
assert!(nets.contains(&"authelia".to_string()));
assert!(nets.contains(&"caddy".to_string()));
}
#[test]
fn networks_authelia_excluded_for_authelia_itself() {
let nets = resolve_extra_networks(
"authelia", true, true, false, false, false, false, None, false,
);
assert!(nets.is_empty());
}
#[test]
fn networks_smtp_joins_inbucket_without_caddy() {
let nets = resolve_extra_networks(
"forgejo", false, false, false, true, false, true, None, false,
);
assert_eq!(nets, vec!["inbucket"]);
}
#[test]
fn networks_smtp_skips_inbucket_when_it_is_self() {
let nets = resolve_extra_networks(
"inbucket", false, false, false, true, false, true, None, false,
);
assert!(!nets.contains(&"inbucket".to_string()));
}
#[test]
fn networks_smtp_skips_inbucket_when_not_installed() {
let nets = resolve_extra_networks(
"forgejo", false, false, false, false, false, true, None, false,
);
assert!(!nets.contains(&"inbucket".to_string()));
}
#[test]
fn networks_metrics_consumer_joins_store() {
let nets = resolve_extra_networks(
"grafana",
false,
false,
false,
false,
false,
false,
Some("prometheus"),
true,
);
assert_eq!(nets, vec!["prometheus".to_string()]);
}
#[test]
fn networks_metrics_store_skips_itself() {
let nets = resolve_extra_networks(
"prometheus",
false,
false,
false,
false,
false,
false,
Some("prometheus"),
true,
);
assert!(nets.is_empty());
}
#[test]
fn networks_metrics_indifferent_service_skips_store() {
let nets = resolve_extra_networks(
"vaultwarden",
false,
false,
false,
false,
false,
false,
Some("prometheus"),
false,
);
assert!(nets.is_empty());
}
#[test]
fn quadlet_belongs_to_exact_match() {
let all = &["foo", "foo-bar"];
assert!(quadlet_belongs_to("foo.container", "foo", all));
assert!(quadlet_belongs_to("foo.network", "foo", all));
}
#[test]
fn quadlet_belongs_to_sidecar() {
let all = &["foo"];
assert!(quadlet_belongs_to("foo-db.volume", "foo", all));
}
#[test]
fn quadlet_belongs_to_rejects_prefix_collision() {
let all = &["foo", "foo-bar"];
assert!(!quadlet_belongs_to("foo-bar.container", "foo", all));
assert!(!quadlet_belongs_to("foo-bar-db.volume", "foo", all));
}
#[test]
fn quadlet_belongs_to_hyphenated_service() {
let all = &["foo", "foo-bar"];
assert!(quadlet_belongs_to("foo-bar.container", "foo-bar", all));
assert!(quadlet_belongs_to("foo-bar-db.volume", "foo-bar", all));
assert!(!quadlet_belongs_to("foo.container", "foo-bar", all));
}
}