use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::capability::Capability;
use crate::error::{Error, Result};
use crate::registry::resolve::ServiceRef;
use crate::registry::service_def::AuthKind;
use crate::{
AddResult, AddServiceParams, AuthChoice, Exposure, Lifecycle, PlanMode, RemoveMode,
RemoveResult, Step, config,
};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExposureRequest {
#[default]
Loopback,
Url(String),
Tailscale(String),
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthRequested {
#[default]
No,
Yes,
Kind(AuthKind),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AddRequest {
pub service: String,
#[serde(default)]
pub exposure: ExposureRequest,
#[serde(default)]
pub auth: AuthRequested,
#[serde(default)]
pub smtp: Option<bool>,
#[serde(default)]
pub backup: bool,
#[serde(default)]
pub env: BTreeMap<String, String>,
#[serde(default)]
pub enable_groups: BTreeSet<String>,
#[serde(default)]
pub choose: BTreeMap<String, String>,
}
impl AddRequest {
pub fn new(service: impl Into<String>) -> Self {
AddRequest {
service: service.into(),
exposure: ExposureRequest::default(),
auth: AuthRequested::default(),
smtp: None,
backup: false,
env: BTreeMap::new(),
enable_groups: BTreeSet::new(),
choose: BTreeMap::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemoveRequest {
pub service: String,
#[serde(default)]
pub mode: RemoveMode,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LifecycleRequest {
pub service: String,
pub action: Lifecycle,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpgradeRequest {
pub service: String,
#[serde(default)]
pub force: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigureRequest {
pub service: String,
pub changes: crate::configure::Overrides,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum Operation {
Add(AddRequest),
Remove(RemoveRequest),
Lifecycle(LifecycleRequest),
Upgrade(UpgradeRequest),
Configure(ConfigureRequest),
BackupRun(BackupRunRequest),
}
pub struct PlanContext<'a> {
pub port_in_use: &'a (dyn Fn(u16) -> bool + Sync),
pub resolved: Option<(&'a ServiceRef, &'a Path)>,
pub pre_built_ctx: Option<BTreeMap<String, String>>,
pub port_overrides: BTreeMap<String, u16>,
pub mode: PlanMode,
pub acme: Option<&'a crate::caddy::AcmeMode>,
}
impl<'a> PlanContext<'a> {
pub fn new(port_in_use: &'a (dyn Fn(u16) -> bool + Sync)) -> Self {
PlanContext {
port_in_use,
resolved: None,
pre_built_ctx: None,
port_overrides: BTreeMap::new(),
mode: PlanMode::Add,
acme: None,
}
}
}
pub struct PlannedAdd {
pub service: String,
pub result: AddResult,
pub exposure: Exposure,
pub auth_kind: Option<AuthKind>,
pub registry_name: String,
pub repo_dir: PathBuf,
pub notes: Vec<String>,
}
impl PlannedAdd {
pub fn record_pending(&self) -> Result<()> {
crate::record_pending(crate::RecordPendingParams {
service_name: &self.service,
auth_kind: self.auth_kind.clone(),
registry_name: &self.registry_name,
allocated_ports: &self.result.allocated_ports,
repo_dir: &self.repo_dir,
exposure: &self.exposure,
})
}
}
pub enum Planned {
Add(Box<PlannedAdd>),
Remove(RemoveResult),
Lifecycle(Vec<Step>),
Upgrade(Box<crate::upgrade::UpgradeResult>),
Configure(Box<crate::configure::ConfigureResult>),
BackupRun(Box<crate::backup::BackupRunPlan>),
}
pub async fn plan(op: &Operation, ctx: PlanContext<'_>) -> Result<Planned> {
match op {
Operation::Add(req) => Ok(Planned::Add(Box::new(plan_add(req, ctx).await?))),
Operation::Remove(req) => Ok(Planned::Remove(plan_remove(req)?)),
Operation::Lifecycle(req) => Ok(Planned::Lifecycle(plan_lifecycle(req)?)),
Operation::Upgrade(req) => Ok(Planned::Upgrade(Box::new(plan_upgrade(req).await?))),
Operation::Configure(req) => Ok(Planned::Configure(Box::new(plan_configure(req).await?))),
Operation::BackupRun(req) => Ok(Planned::BackupRun(Box::new(plan_backup_run(req).await?))),
}
}
pub async fn plan_upgrade(req: &UpgradeRequest) -> Result<crate::upgrade::UpgradeResult> {
crate::upgrade::upgrade_service(&req.service, req.force).await
}
pub async fn plan_configure(req: &ConfigureRequest) -> Result<crate::configure::ConfigureResult> {
crate::configure::configure_service(&req.service, &req.changes).await
}
pub async fn plan_add(req: &AddRequest, ctx: PlanContext<'_>) -> Result<PlannedAdd> {
let mut notes = Vec::new();
let (service_ref, repo_dir) = match ctx.resolved {
Some((r, d)) => (r.clone(), d.to_path_buf()),
None => {
let r = ServiceRef::parse(&req.service)?;
let d = crate::resolve_registry_dir(&r).await?;
(r, d)
}
};
let service = service_ref.service_name().to_string();
let reg_service = crate::registry::find_service(&repo_dir, &service)?;
let paths = config::ConfigPaths::resolve()?;
let cfg = config::load_or_default(&paths.config_file)?;
let supported = ®_service.def.integrations.auth;
let auth_kind: Option<AuthKind> = match &req.auth {
AuthRequested::No => None,
AuthRequested::Yes => match supported.first() {
Some(kind) => Some(kind.clone()),
None if reg_service
.def
.capabilities
.provides
.contains(&Capability::OidcProvider) =>
{
notes.push(format!(
"{service} is the auth provider itself; auth has no effect"
));
None
}
None => return Err(Error::NoOidcSupport(service)),
},
AuthRequested::Kind(kind) => {
if !supported.contains(kind) {
return Err(Error::NoOidcSupport(service));
}
Some(kind.clone())
}
};
if auth_kind.is_some() && cfg.auth.is_none() {
return Err(Error::AuthNotConfigured);
}
let enable_smtp = req.smtp.unwrap_or(cfg.smtp.is_some());
if enable_smtp && cfg.smtp.is_none() {
return Err(Error::ConfigValidation(format!(
"SMTP requested for '{service}' but no SMTP provider is configured \
(add inbucket, or configure SMTP first)"
)));
}
let requested_url = match &req.exposure {
ExposureRequest::Url(u) => Some(u.as_str()),
_ => None,
};
let needs_https = reg_service
.def
.service
.https
.needs_https(auth_kind.is_some(), requested_url);
let exposure = match &req.exposure {
ExposureRequest::Url(u) => Exposure::from_url(u),
ExposureRequest::Tailscale(u) => Exposure::Tailscale { url: u.clone() },
ExposureRequest::Loopback if needs_https => {
if crate::is_service_installed("caddy") {
let https_port = crate::well_known::caddy_https_port(&cfg);
let url = format!(
"https://{service}.{}:{https_port}",
config::schema::CADDY_LOCAL_DOMAIN
);
notes.push(format!("{service} requires HTTPS; exposing at {url}"));
Exposure::from_url(&url)
} else {
return Err(Error::ConfigValidation(format!(
"service '{service}' requires HTTPS but no exposure was given: \
pass a URL or tailscale exposure, or add caddy first"
)));
}
}
ExposureRequest::Loopback => Exposure::Loopback,
};
let auth_choice = match &auth_kind {
Some(kind) => AuthChoice::Native(kind.clone()),
None => AuthChoice::None,
};
let result = crate::add_service(AddServiceParams {
service_name: &service,
exposure: &exposure,
auth: auth_choice,
enable_smtp,
enable_backup: req.backup,
env_overrides: &req.env,
enabled_groups: &req.enable_groups,
selected_choices: &req.choose,
registry_name: service_ref.registry_name(),
repo_dir: &repo_dir,
pre_built_ctx: ctx.pre_built_ctx,
port_in_use: ctx.port_in_use,
acme_mode: ctx.acme,
mode: ctx.mode,
port_overrides: &ctx.port_overrides,
})?;
Ok(PlannedAdd {
registry_name: service_ref.registry_name().to_string(),
service,
result,
exposure,
auth_kind,
repo_dir,
notes,
})
}
pub fn plan_remove(req: &RemoveRequest) -> Result<RemoveResult> {
crate::remove_service(&req.service, req.mode)
}
pub fn plan_lifecycle(req: &LifecycleRequest) -> Result<Vec<Step>> {
crate::lifecycle_steps(&req.service, req.action)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackupRunRequest {
pub service: String,
}
pub async fn plan_backup_run(req: &BackupRunRequest) -> Result<crate::backup::BackupRunPlan> {
let paths = config::ConfigPaths::resolve()?;
let cfg = config::load_or_default(&paths.config_file)?;
let installed = crate::list_installed()?
.into_iter()
.find(|s| s.name == req.service)
.ok_or_else(|| Error::ServiceNotInstalled(req.service.clone()))?;
let service_ref = crate::service_ref_from_installed(&installed);
let repo_dir = crate::resolve_registry_dir(&service_ref).await?;
crate::backup::plan_backup_run(&req.service, &cfg, &repo_dir)
}