use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use crate::config::ConfigPaths;
use crate::error::{Error, Result};
use crate::exposure::Exposure;
use crate::generate::GeneratedFile;
use crate::metadata::load_metadata;
use crate::registry::resolve::ServiceRef;
use crate::registry::service_def::{AuthKind, EnvFormat};
use crate::system::secret;
use crate::upgrade::{DiffEntry, DiffKind, DiffResult, EnvAddition};
use crate::{
AddResult, PlanMode, REGISTRY_DEFAULT, Step, WellKnownService, add_service, authelia, caddy,
config, is_service_installed, list_installed, manifest, quadlet_dir, registry,
resolve_registry_dir, service_home,
};
#[derive(Debug, Clone, Default)]
pub struct Overrides {
pub exposure: Option<ExposureChange>,
pub smtp: Option<bool>,
pub backup: Option<bool>,
pub auth: Option<bool>,
pub enable_groups: BTreeSet<String>,
pub disable_groups: BTreeSet<String>,
pub env_overrides: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExposureChange {
Url(String),
Tailscale(String),
Loopback,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigureChange {
Url {
from: Option<String>,
to: Option<String>,
},
Smtp { from: bool, to: bool },
Backup { from: bool, to: bool },
Auth { from: bool, to: bool },
GroupEnabled(String),
GroupDisabled(String),
EnvOverride {
key: String,
from: Option<String>,
to: String,
},
}
impl ConfigureChange {
pub fn is_destructive(&self) -> bool {
match self {
ConfigureChange::Url { from, to } => from.is_some() && from != to,
ConfigureChange::Smtp {
from: true,
to: false,
} => true,
ConfigureChange::Backup {
from: true,
to: false,
} => true,
ConfigureChange::Auth {
from: true,
to: false,
} => true,
ConfigureChange::GroupDisabled(_) => true,
ConfigureChange::Smtp { .. } => false,
ConfigureChange::Backup { .. } => false,
ConfigureChange::Auth { .. } => false,
ConfigureChange::GroupEnabled(_) => false,
ConfigureChange::EnvOverride { .. } => false,
}
}
}
pub struct ConfigureResult {
pub service: String,
pub changes: Vec<ConfigureChange>,
pub diff: DiffResult,
pub steps: Vec<Step>,
pub has_destructive: bool,
}
impl ConfigureResult {
pub fn is_noop(&self) -> bool {
self.steps.is_empty()
}
}
pub async fn configure_service(
service_name: &str,
overrides: &Overrides,
) -> Result<ConfigureResult> {
if !is_service_installed(service_name) {
return Err(Error::ServiceNotInstalled(service_name.to_string()));
}
let metadata = load_metadata(service_name)?
.ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
let current_url: Option<String> = metadata.url.clone();
let current_smtp: bool = metadata.smtp_enabled;
let current_backup: bool = metadata.backup_enabled;
let current_auth: bool = metadata.auth.is_some();
let current_groups: BTreeSet<String> = metadata.enabled_groups.iter().cloned().collect();
let target_url: Option<String> = match &overrides.exposure {
None => current_url.clone(),
Some(ExposureChange::Loopback) => None,
Some(ExposureChange::Url(u)) => Some(u.clone()),
Some(ExposureChange::Tailscale(u)) => Some(u.clone()),
};
let target_smtp: bool = overrides.smtp.unwrap_or(current_smtp);
let target_backup: bool = overrides.backup.unwrap_or(current_backup);
let target_auth: bool = overrides.auth.unwrap_or(current_auth);
let service_ref = if metadata.registry.is_empty() || metadata.registry == REGISTRY_DEFAULT {
ServiceRef::Default(service_name.to_string())
} else {
ServiceRef::Custom {
registry: metadata.registry.clone(),
service: service_name.to_string(),
}
};
let repo_dir = resolve_registry_dir(&service_ref).await?;
let reg_service = registry::find_service(&repo_dir, service_name)?;
let known_groups: BTreeSet<&str> = reg_service
.def
.env_groups
.iter()
.map(|g| g.name.as_str())
.collect();
for g in overrides
.enable_groups
.iter()
.chain(overrides.disable_groups.iter())
{
if !known_groups.contains(g.as_str()) {
let known: Vec<String> = known_groups.iter().map(|s| (*s).to_string()).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,
});
}
}
for g in &overrides.enable_groups {
if overrides.disable_groups.contains(g) {
return Err(Error::ConfigureUnsupported {
service: service_name.to_string(),
field: format!("env_group '{g}'"),
workaround:
"group can't appear in both --enable and --disable in one configure run"
.to_string(),
});
}
}
if target_backup && !reg_service.def.integrations.backup {
return Err(Error::BackupNotSupported(service_name.to_string()));
}
if !current_auth
&& target_auth
&& reg_service.def.integrations.auth.is_empty()
&& !crate::capability::def_provides(®_service.def, crate::Capability::OidcProvider)
{
return Err(Error::NoOidcSupport(service_name.to_string()));
}
let url_changed_pre = current_url != target_url;
let needs_register_pre = target_auth && (!current_auth || url_changed_pre);
if needs_register_pre && target_url.is_none() {
return Err(Error::ConfigureUnsupported {
service: service_name.to_string(),
field: "auth without url".to_string(),
workaround: "auth needs a public URL for the OIDC redirect_uri; pass `--url <URL>` \
alongside `--auth`, or use `--no-auth` to disable auth"
.to_string(),
});
}
let mut target_groups = current_groups.clone();
for g in &overrides.enable_groups {
target_groups.insert(g.clone());
}
for g in &overrides.disable_groups {
target_groups.remove(g);
}
let mut pre_built_ctx = recover_template_ctx(service_name, ®_service.def)?;
let mut minted_oidc: Option<(String, String)> = None;
if !current_auth && target_auth {
let client_id = secret::generate(&EnvFormat::Uuid, None);
let client_secret = secret::generate(&EnvFormat::String, Some(64));
pre_built_ctx.insert("auth.client_id".into(), client_id.clone());
pre_built_ctx.insert("auth.client_secret".into(), client_secret.clone());
minted_oidc = Some((client_id, client_secret));
}
let port_overrides = read_existing_ports(service_name)?;
let port_in_use = |_p: u16| false;
let target_exposure: Exposure = match &target_url {
None => Exposure::Loopback,
Some(u) => Exposure::from_url(u),
};
let prior_kind = current_url
.as_deref()
.map(Exposure::from_url)
.unwrap_or(Exposure::Loopback);
let target_auth_kind: Option<AuthKind> = if target_auth {
Some(AuthKind::Oidc)
} else {
None
};
let result = add_service(
service_name,
&target_exposure,
target_auth_kind.clone(),
target_auth,
target_smtp,
target_backup,
&overrides.env_overrides,
&target_groups,
&metadata.registry,
&repo_dir,
Some(pre_built_ctx.clone()),
&port_in_use,
None,
PlanMode::Upgrade,
&port_overrides,
)?;
let diff = build_diff(service_name, &result)?;
let mut changes: Vec<ConfigureChange> = Vec::new();
if current_url != target_url {
changes.push(ConfigureChange::Url {
from: current_url.clone(),
to: target_url.clone(),
});
}
if current_auth != target_auth {
changes.push(ConfigureChange::Auth {
from: current_auth,
to: target_auth,
});
}
if current_smtp != target_smtp {
changes.push(ConfigureChange::Smtp {
from: current_smtp,
to: target_smtp,
});
}
if current_backup != target_backup {
changes.push(ConfigureChange::Backup {
from: current_backup,
to: target_backup,
});
}
for g in target_groups.difference(¤t_groups) {
changes.push(ConfigureChange::GroupEnabled(g.clone()));
}
for g in current_groups.difference(&target_groups) {
changes.push(ConfigureChange::GroupDisabled(g.clone()));
}
let existing_env = read_existing_env_keys(service_name)?;
for (key, val) in &overrides.env_overrides {
let prior = existing_env.get(key).cloned();
if prior.as_deref() != Some(val.as_str()) {
changes.push(ConfigureChange::EnvOverride {
key: key.clone(),
from: prior,
to: val.clone(),
});
}
}
let has_destructive = changes.iter().any(|c| c.is_destructive());
let url_changed = current_url != target_url;
let needs_unregister = current_auth && (!target_auth || url_changed);
let needs_register = target_auth && (!current_auth || url_changed);
let prior_is_ts = matches!(prior_kind, Exposure::Tailscale { .. });
let target_is_ts = matches!(target_exposure, Exposure::Tailscale { .. });
let needs_tailscale_disable = prior_is_ts && !target_is_ts;
let needs_tailscale_enable = target_is_ts && !prior_is_ts;
let no_user_request = changes.is_empty()
&& !needs_unregister
&& !needs_register
&& !needs_tailscale_disable
&& !needs_tailscale_enable;
let steps = if no_user_request {
Vec::new()
} else {
build_configure_steps(
service_name,
&result,
®_service.def,
&diff,
current_url.as_deref(),
target_url.as_deref(),
needs_unregister,
needs_register,
needs_tailscale_disable,
needs_tailscale_enable,
minted_oidc.as_ref(),
)?
};
Ok(ConfigureResult {
service: service_name.to_string(),
changes,
diff,
steps,
has_destructive,
})
}
fn build_diff(service_name: &str, result: &AddResult) -> Result<DiffResult> {
let manifest_file = manifest::manifest_path(service_name)?;
let (manifest_entries, _) = manifest::load(service_name)?.unwrap_or_default();
let manifest_by_path: BTreeMap<PathBuf, String> = manifest_entries
.into_iter()
.map(|e| (e.path, e.sha256))
.collect();
let planned: BTreeMap<PathBuf, String> = result
.steps
.iter()
.filter_map(|s| match s {
Step::WriteFile(f) => Some((f.path.clone(), f.content.clone())),
_ => None,
})
.collect();
let existing_env = read_existing_env_keys(service_name)?;
let env_additions: Vec<EnvAddition> = result
.tracked_envs
.iter()
.filter(|p| !existing_env.contains_key(&p.key))
.map(|p| EnvAddition {
key: p.key.clone(),
value: p.value.clone(),
kind: p.kind.clone(),
prompt: p.prompt.clone(),
})
.collect();
let mut entries: Vec<DiffEntry> = Vec::new();
let mut seen: BTreeSet<PathBuf> = BTreeSet::new();
let env_filename = std::ffi::OsStr::new(".env");
for (path, content) in &planned {
seen.insert(path.clone());
let planned_hash = manifest::hash_bytes(content.as_bytes());
let on_disk_hash = if path.exists() {
Some(manifest::hash_file(path)?)
} else {
None
};
let manifest_hash = manifest_by_path.get(path);
let is_env = path.file_name() == Some(env_filename);
let is_manifest = path == &manifest_file;
let kind = match (on_disk_hash.as_deref(), manifest_hash.map(String::as_str)) {
(None, _) => match manifest_hash {
Some(_) => DiffKind::Modified,
None => DiffKind::Added,
},
(Some(d), _) if d == planned_hash => DiffKind::Unchanged,
(Some(_), None) if is_env || is_manifest => DiffKind::Modified,
(Some(_), None) => DiffKind::Drift,
(Some(d), Some(l)) if d == l => DiffKind::Modified,
(Some(_), Some(_)) => DiffKind::Drift,
};
entries.push(DiffEntry {
path: path.clone(),
kind,
});
}
for path in manifest_by_path.keys() {
if seen.contains(path) {
continue;
}
entries.push(DiffEntry {
path: path.clone(),
kind: DiffKind::Removed,
});
}
entries.sort_by(|a, b| a.path.cmp(&b.path));
Ok(DiffResult {
service: service_name.to_string(),
entries,
env_additions,
})
}
#[allow(clippy::too_many_arguments)]
fn build_configure_steps(
service_name: &str,
result: &AddResult,
service_def: ®istry::service_def::ServiceDef,
diff: &DiffResult,
current_url: Option<&str>,
target_url: Option<&str>,
needs_unregister: bool,
needs_register: bool,
needs_tailscale_disable: bool,
needs_tailscale_enable: bool,
minted_oidc: Option<&(String, String)>,
) -> Result<Vec<Step>> {
let unchanged: BTreeSet<PathBuf> = diff
.entries
.iter()
.filter(|e| matches!(e.kind, DiffKind::Unchanged))
.map(|e| e.path.clone())
.collect();
let mut writes: Vec<Step> = Vec::new();
let mut copies: Vec<Step> = Vec::new();
let mut kept_caddyfile = false;
let mut kept_quadlet = false;
let caddyfile_path = caddy::caddyfile_path().ok();
let home_dir = service_home(service_name)?;
for step in &result.steps {
match step {
Step::StartService { .. } => continue,
Step::CreateDir(p) if p == &home_dir => continue,
Step::PullImage { .. } => continue,
Step::DaemonReload | Step::ReloadCaddy | Step::Symlink { .. } => continue,
Step::TailscaleSetup | Step::TailscaleEnable { .. } | Step::TailscaleDisable { .. } => {
continue;
}
Step::WriteFile(file) => {
if unchanged.contains(&file.path) {
continue;
}
if Some(&file.path) == caddyfile_path.as_ref() {
kept_caddyfile = true;
}
if is_quadlet_filename(&file.path) {
kept_quadlet = true;
}
writes.push(Step::WriteFile(GeneratedFile {
path: file.path.clone(),
content: file.content.clone(),
}));
}
Step::CopyFile { src, dst } => {
copies.push(Step::CopyFile {
src: src.clone(),
dst: dst.clone(),
});
}
other => copies.push(clone_step(other)),
}
}
let mut removals: Vec<Step> = Vec::new();
for entry in &diff.entries {
if matches!(entry.kind, DiffKind::Removed) && entry.path.exists() {
removals.push(Step::RemoveFile(entry.path.clone()));
}
}
let prior_exp = current_url
.map(Exposure::from_url)
.unwrap_or(Exposure::Loopback);
let target_exp = target_url
.map(Exposure::from_url)
.unwrap_or(Exposure::Loopback);
let prior_caddy = matches!(
prior_exp,
Exposure::Internal { .. } | Exposure::Public { .. }
);
let target_caddy = matches!(
target_exp,
Exposure::Internal { .. } | Exposure::Public { .. }
);
let mut url_teardown: Vec<Step> = Vec::new();
if prior_caddy
&& !target_caddy
&& let Some(prev) = current_url
&& let Some(s) = caddy_remove_route_steps(service_name, prev)?
{
url_teardown = s;
kept_caddyfile = true;
}
let mut unregister_steps: Vec<Step> = Vec::new();
if needs_unregister {
unregister_steps = authelia::unregister_oidc_client(service_name)?;
}
let mut tailscale_disable_steps: Vec<Step> = Vec::new();
if needs_tailscale_disable
&& let Some(svc_name) = current_url
.map(Exposure::from_url)
.as_ref()
.and_then(|e| e.tailscale_svc_name())
{
tailscale_disable_steps.push(Step::TailscaleDisable { svc_name });
}
let mut register_steps: Vec<Step> = Vec::new();
if needs_register {
let (client_id, client_secret) = match minted_oidc {
Some((id, secret)) => (id.clone(), secret.clone()),
None => {
let env = read_existing_env_keys(service_name)?;
let id = service_def
.mappings
.auth
.iter()
.find(|(_, v)| v.trim() == "{{auth.client_id}}")
.and_then(|(k, _)| env.get(k).map(|v| trim_env_value(v)))
.ok_or_else(|| {
Error::AuthContext(format!(
"service '{service_name}' has auth=oidc in metadata but no \
OAUTH_CLIENT_ID-shaped env var found — cannot re-register OIDC \
client at the new URL"
))
})?;
let secret = service_def
.mappings
.auth
.iter()
.find(|(_, v)| v.trim() == "{{auth.client_secret}}")
.and_then(|(k, _)| env.get(k).map(|v| trim_env_value(v)))
.unwrap_or_default();
(id, secret)
}
};
let mut ctx: BTreeMap<String, String> = BTreeMap::new();
ctx.insert("auth.client_id".into(), client_id);
ctx.insert("auth.client_secret".into(), client_secret);
if let Some(u) = target_url {
ctx.insert("service.url".into(), u.to_string());
}
let paths = ConfigPaths::resolve()?;
let cfg = config::load_or_default(&paths.config_file)?;
let qdir = quadlet_dir()?;
register_steps = authelia::register_oidc_client(
service_name,
service_def,
target_url,
&ctx,
&cfg,
&qdir,
)?;
}
let mut tailscale_enable_steps: Vec<Step> = Vec::new();
if needs_tailscale_enable
&& let Some(svc_name) = target_url
.map(Exposure::from_url)
.as_ref()
.and_then(|e| e.tailscale_svc_name())
{
let primary = result
.allocated_ports
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case("http"))
.or_else(|| result.allocated_ports.first())
.map(|(_, p)| *p);
let ts_ports =
crate::plan::tailscale_ports(&service_def.ports, &result.allocated_ports, primary);
if !ts_ports.is_empty() {
tailscale_enable_steps.push(Step::TailscaleSetup);
tailscale_enable_steps.push(Step::TailscaleEnable {
svc_name,
ports: ts_ports,
});
}
}
let any_file_change = !writes.is_empty() || !removals.is_empty() || !url_teardown.is_empty();
let any_lifecycle = !unregister_steps.is_empty()
|| !register_steps.is_empty()
|| !tailscale_disable_steps.is_empty()
|| !tailscale_enable_steps.is_empty();
if !any_file_change && !any_lifecycle {
return Ok(Vec::new());
}
let manifest_file = manifest::manifest_path(service_name).ok();
let metadata_file = manifest_file
.as_ref()
.and_then(|p| p.parent().map(|p| p.join("metadata.toml")));
let writes_affect_runtime = writes.iter().any(|s| match s {
Step::WriteFile(f) => {
Some(&f.path) != metadata_file.as_ref() && Some(&f.path) != manifest_file.as_ref()
}
_ => false,
});
let needs_restart =
writes_affect_runtime || !removals.is_empty() || !url_teardown.is_empty() || any_lifecycle;
let mut steps: Vec<Step> = Vec::new();
for step in &result.steps {
if let Step::Symlink { link, target } = step
&& writes
.iter()
.any(|s| matches!(s, Step::WriteFile(f) if &f.path == target))
{
steps.push(Step::Symlink {
link: link.clone(),
target: target.clone(),
});
}
}
steps.splice(0..0, writes);
steps.extend(copies);
steps.extend(removals);
steps.extend(url_teardown);
steps.extend(unregister_steps);
steps.extend(tailscale_disable_steps);
if kept_quadlet {
steps.push(Step::DaemonReload);
}
if kept_caddyfile {
steps.push(Step::ReloadCaddy);
}
steps.extend(tailscale_enable_steps);
steps.extend(register_steps);
if needs_restart {
steps.push(Step::RestartService {
unit: service_name.to_string(),
});
}
Ok(steps)
}
fn caddy_remove_route_steps(service_name: &str, prior_url: &str) -> Result<Option<Vec<Step>>> {
use crate::{Capability, find_installed_provider};
let installed = list_installed().unwrap_or_default();
if find_installed_provider(&installed, Capability::ReverseProxy).is_none() {
return Ok(None);
}
let prior_exp = Exposure::from_url(prior_url);
if matches!(prior_exp, Exposure::Loopback | Exposure::Tailscale { .. }) {
return Ok(None);
}
if WellKnownService::Caddy.matches(service_name) {
return Ok(None);
}
let caddyfile_path = caddy::caddyfile_path()?;
if !caddyfile_path.exists() {
return Ok(None);
}
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 {
return Ok(None);
}
let mut out: Vec<Step> = Vec::new();
out.push(Step::WriteFile(GeneratedFile {
path: caddyfile_path,
content: updated.clone(),
}));
if !updated.trim().is_empty() {
out.push(Step::ReloadCaddy);
}
Ok(Some(out))
}
fn recover_template_ctx(
service_name: &str,
def: ®istry::service_def::ServiceDef,
) -> Result<BTreeMap<String, String>> {
let existing_env = read_existing_env_keys(service_name)?;
if existing_env.is_empty() {
return Ok(BTreeMap::new());
}
let mut ctx = BTreeMap::new();
let collect_secrets = |value: &str, out: &mut Vec<String>| {
let mut rest = value;
while let Some(start) = rest.find("{{secret.") {
let after = &rest[start + 9..];
if let Some(end) = after.find("}}") {
out.push(after[..end].to_string());
rest = &after[end + 2..];
} else {
break;
}
}
};
let collect_auth = |value: &str, out: &mut Vec<String>| {
for needle in ["{{auth.client_id", "{{auth.client_secret"] {
if value.contains(needle) {
let stripped = needle.trim_start_matches("{{auth.");
out.push(stripped.to_string());
}
}
};
let mut secret_pairs: Vec<(String, String)> = Vec::new();
let mut auth_keys: Vec<String> = Vec::new();
let mut consider = |env: ®istry::service_def::EnvVar| {
let trimmed = env.value.trim();
if let Some(name) = trimmed
.strip_prefix("{{secret.")
.and_then(|s| s.strip_suffix("}}"))
&& let Some(live) = existing_env.get(&env.name)
{
secret_pairs.push((name.to_string(), trim_env_value(live)));
}
let mut extras: Vec<String> = Vec::new();
collect_secrets(&env.value, &mut extras);
for n in extras {
if !secret_pairs.iter().any(|(k, _)| k == &n) {
secret_pairs.push((n, String::new()));
}
}
let mut auth_refs: Vec<String> = Vec::new();
collect_auth(&env.value, &mut auth_refs);
for n in auth_refs {
if !auth_keys.contains(&n) {
auth_keys.push(n);
}
}
};
for e in &def.env {
consider(e);
}
for g in &def.env_groups {
for e in &g.env {
consider(e);
}
}
for (env_name, value_template) in &def.mappings.auth {
let env = registry::service_def::EnvVar {
name: env_name.clone(),
value: value_template.clone(),
kind: Default::default(),
prompt: None,
format: Default::default(),
length: None,
jwt_claims: None,
jwt_signing_key: None,
};
consider(&env);
}
for (name, value) in &secret_pairs {
if !value.is_empty() {
ctx.insert(format!("secret.{name}"), value.clone());
}
}
for (env_name, value_template) in &def.mappings.auth {
let trimmed = value_template.trim();
if let Some(rest) = trimmed
.strip_prefix("{{auth.")
.and_then(|s| s.strip_suffix("}}"))
&& let Some(live) = existing_env.get(env_name)
{
ctx.insert(format!("auth.{rest}"), trim_env_value(live));
}
}
Ok(ctx)
}
fn trim_env_value(raw: &str) -> String {
raw.trim_matches(|c: char| c == '"' || c == '\'')
.to_string()
}
fn is_quadlet_filename(path: &std::path::Path) -> bool {
matches!(
path.extension().and_then(|e| e.to_str()),
Some("container" | "volume" | "network" | "kube" | "image" | "pod" | "build")
)
}
fn read_existing_env_keys(service_name: &str) -> Result<BTreeMap<String, String>> {
let env_path = service_home(service_name)?.join(".env");
let mut out: BTreeMap<String, String> = BTreeMap::new();
let content = match std::fs::read_to_string(&env_path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
Err(source) => {
return Err(Error::FileRead {
path: env_path,
source,
});
}
};
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((k, v)) = line.split_once('=') {
out.insert(k.trim().to_string(), v.to_string());
}
}
Ok(out)
}
fn read_existing_ports(service_name: &str) -> Result<BTreeMap<String, u16>> {
let env_path = service_home(service_name)?.join(".env");
let mut overrides = BTreeMap::new();
let content = match std::fs::read_to_string(&env_path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(overrides),
Err(source) => {
return Err(Error::FileRead {
path: env_path,
source,
});
}
};
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let Some(name) = key.strip_prefix("SERVICE_PORT_") else {
continue;
};
if let Ok(port) = value.trim().parse::<u16>() {
overrides.insert(name.to_ascii_lowercase(), port);
}
}
Ok(overrides)
}
fn clone_step(step: &Step) -> Step {
match step {
Step::WriteFile(f) => Step::WriteFile(GeneratedFile {
path: f.path.clone(),
content: f.content.clone(),
}),
Step::Symlink { link, target } => Step::Symlink {
link: link.clone(),
target: target.clone(),
},
Step::DaemonReload => Step::DaemonReload,
Step::StartService { unit } => Step::StartService { unit: unit.clone() },
Step::StopService { unit } => Step::StopService { unit: unit.clone() },
Step::RestartService { unit } => Step::RestartService { unit: unit.clone() },
Step::ReloadCaddy => Step::ReloadCaddy,
Step::PullImage { image } => Step::PullImage {
image: image.clone(),
},
Step::RemoveFile(p) => Step::RemoveFile(p.clone()),
Step::RemoveDir(p) => Step::RemoveDir(p.clone()),
Step::RemoveVolume { name } => Step::RemoveVolume { name: name.clone() },
Step::RemoveNetwork { name } => Step::RemoveNetwork { name: name.clone() },
Step::CreateDir(p) => Step::CreateDir(p.clone()),
Step::WaitForFile { path, timeout_secs } => Step::WaitForFile {
path: path.clone(),
timeout_secs: *timeout_secs,
},
Step::CopyFile { src, dst } => Step::CopyFile {
src: src.clone(),
dst: dst.clone(),
},
Step::Build { dir, command } => Step::Build {
dir: dir.clone(),
command: command.clone(),
},
Step::TailscaleSetup => Step::TailscaleSetup,
Step::TailscaleEnable { svc_name, ports } => Step::TailscaleEnable {
svc_name: svc_name.clone(),
ports: ports.clone(),
},
Step::TailscaleDisable { svc_name } => Step::TailscaleDisable {
svc_name: svc_name.clone(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn destructive_classification() {
let url = |from: Option<&str>, to: Option<&str>| ConfigureChange::Url {
from: from.map(str::to_string),
to: to.map(str::to_string),
};
let cases: &[(ConfigureChange, bool)] = &[
(url(Some("https://old"), Some("https://new")), true),
(url(Some("https://old"), None), true),
(url(None, Some("https://new")), false),
(url(Some("https://x"), Some("https://x")), false),
(
ConfigureChange::Smtp {
from: true,
to: false,
},
true,
),
(
ConfigureChange::Smtp {
from: false,
to: true,
},
false,
),
(
ConfigureChange::Backup {
from: true,
to: false,
},
true,
),
(
ConfigureChange::Backup {
from: false,
to: true,
},
false,
),
(
ConfigureChange::Auth {
from: true,
to: false,
},
true,
),
(
ConfigureChange::Auth {
from: false,
to: true,
},
false,
),
(ConfigureChange::GroupDisabled("oauth".into()), true),
(ConfigureChange::GroupEnabled("oauth".into()), false),
(
ConfigureChange::EnvOverride {
key: "ADMIN_EMAIL".into(),
from: Some("a".into()),
to: "b".into(),
},
false,
),
];
for (change, expected) in cases {
assert_eq!(
change.is_destructive(),
*expected,
"wrong classification for {change:?}"
);
}
}
}