1use std::collections::{BTreeMap, BTreeSet};
17use std::path::{Path, PathBuf};
18
19use serde::{Deserialize, Serialize};
20
21use crate::capability::Capability;
22use crate::error::{Error, Result};
23use crate::registry::resolve::ServiceRef;
24use crate::registry::service_def::AuthKind;
25use crate::{
26 AddResult, AddServiceParams, AuthChoice, Exposure, Lifecycle, PlanMode, RemoveMode,
27 RemoveResult, Step, config,
28};
29
30#[derive(Debug, Clone, Default, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum ExposureRequest {
35 #[default]
39 Loopback,
40 Url(String),
42 Tailscale(String),
46}
47
48#[derive(Debug, Clone, Default, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum AuthRequested {
52 #[default]
53 No,
54 Yes,
56 Kind(AuthKind),
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct AddRequest {
62 pub service: String,
64 #[serde(default)]
65 pub exposure: ExposureRequest,
66 #[serde(default)]
67 pub auth: AuthRequested,
68 #[serde(default)]
72 pub smtp: Option<bool>,
73 #[serde(default)]
74 pub backup: bool,
75 #[serde(default)]
76 pub env: BTreeMap<String, String>,
77 #[serde(default)]
78 pub enable_groups: BTreeSet<String>,
79 #[serde(default)]
82 pub choose: BTreeMap<String, String>,
83}
84
85impl AddRequest {
86 pub fn new(service: impl Into<String>) -> Self {
88 AddRequest {
89 service: service.into(),
90 exposure: ExposureRequest::default(),
91 auth: AuthRequested::default(),
92 smtp: None,
93 backup: false,
94 env: BTreeMap::new(),
95 enable_groups: BTreeSet::new(),
96 choose: BTreeMap::new(),
97 }
98 }
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct RemoveRequest {
103 pub service: String,
104 #[serde(default)]
105 pub mode: RemoveMode,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct LifecycleRequest {
110 pub service: String,
111 pub action: Lifecycle,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct UpgradeRequest {
116 pub service: String,
117 #[serde(default)]
120 pub force: bool,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct ConfigureRequest {
128 pub service: String,
129 pub changes: crate::configure::Overrides,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
136#[serde(tag = "op", rename_all = "snake_case")]
137pub enum Operation {
138 Add(AddRequest),
139 Remove(RemoveRequest),
140 Lifecycle(LifecycleRequest),
141 Upgrade(UpgradeRequest),
142 Configure(ConfigureRequest),
143 BackupRun(BackupRunRequest),
144}
145
146pub struct PlanContext<'a> {
150 pub port_in_use: &'a (dyn Fn(u16) -> bool + Sync),
153 pub resolved: Option<(&'a ServiceRef, &'a Path)>,
157 pub pre_built_ctx: Option<BTreeMap<String, String>>,
160 pub port_overrides: BTreeMap<String, u16>,
162 pub mode: PlanMode,
163 pub acme: Option<&'a crate::caddy::AcmeMode>,
167}
168
169impl<'a> PlanContext<'a> {
170 pub fn new(port_in_use: &'a (dyn Fn(u16) -> bool + Sync)) -> Self {
171 PlanContext {
172 port_in_use,
173 resolved: None,
174 pre_built_ctx: None,
175 port_overrides: BTreeMap::new(),
176 mode: PlanMode::Add,
177 acme: None,
178 }
179 }
180}
181
182pub struct PlannedAdd {
185 pub service: String,
187 pub result: AddResult,
188 pub exposure: Exposure,
189 pub auth_kind: Option<AuthKind>,
190 pub registry_name: String,
191 pub repo_dir: PathBuf,
192 pub notes: Vec<String>,
195}
196
197impl PlannedAdd {
198 pub fn record_pending(&self) -> Result<()> {
201 crate::record_pending(crate::RecordPendingParams {
202 service_name: &self.service,
203 auth_kind: self.auth_kind.clone(),
204 registry_name: &self.registry_name,
205 allocated_ports: &self.result.allocated_ports,
206 repo_dir: &self.repo_dir,
207 exposure: &self.exposure,
208 })
209 }
210}
211
212pub enum Planned {
215 Add(Box<PlannedAdd>),
216 Remove(RemoveResult),
217 Lifecycle(Vec<Step>),
218 Upgrade(Box<crate::upgrade::UpgradeResult>),
219 Configure(Box<crate::configure::ConfigureResult>),
220 BackupRun(Box<crate::backup::BackupRunPlan>),
221}
222
223pub async fn plan(op: &Operation, ctx: PlanContext<'_>) -> Result<Planned> {
225 match op {
226 Operation::Add(req) => Ok(Planned::Add(Box::new(plan_add(req, ctx).await?))),
227 Operation::Remove(req) => Ok(Planned::Remove(plan_remove(req)?)),
228 Operation::Lifecycle(req) => Ok(Planned::Lifecycle(plan_lifecycle(req)?)),
229 Operation::Upgrade(req) => Ok(Planned::Upgrade(Box::new(plan_upgrade(req).await?))),
230 Operation::Configure(req) => Ok(Planned::Configure(Box::new(plan_configure(req).await?))),
231 Operation::BackupRun(req) => Ok(Planned::BackupRun(Box::new(plan_backup_run(req).await?))),
232 }
233}
234
235pub async fn plan_upgrade(req: &UpgradeRequest) -> Result<crate::upgrade::UpgradeResult> {
236 crate::upgrade::upgrade_service(&req.service, req.force).await
237}
238
239pub async fn plan_configure(req: &ConfigureRequest) -> Result<crate::configure::ConfigureResult> {
240 crate::configure::configure_service(&req.service, &req.changes).await
241}
242
243pub async fn plan_add(req: &AddRequest, ctx: PlanContext<'_>) -> Result<PlannedAdd> {
244 let mut notes = Vec::new();
245
246 let (service_ref, repo_dir) = match ctx.resolved {
248 Some((r, d)) => (r.clone(), d.to_path_buf()),
249 None => {
250 let r = ServiceRef::parse(&req.service)?;
251 let d = crate::resolve_registry_dir(&r).await?;
252 (r, d)
253 }
254 };
255 let service = service_ref.service_name().to_string();
256 let reg_service = crate::registry::find_service(&repo_dir, &service)?;
257 let paths = config::ConfigPaths::resolve()?;
258 let cfg = config::load_or_default(&paths.config_file)?;
259
260 let supported = ®_service.def.integrations.auth;
264 let auth_kind: Option<AuthKind> = match &req.auth {
265 AuthRequested::No => None,
266 AuthRequested::Yes => match supported.first() {
267 Some(kind) => Some(kind.clone()),
268 None if reg_service
269 .def
270 .capabilities
271 .provides
272 .contains(&Capability::OidcProvider) =>
273 {
274 notes.push(format!(
275 "{service} is the auth provider itself; auth has no effect"
276 ));
277 None
278 }
279 None => return Err(Error::NoOidcSupport(service)),
280 },
281 AuthRequested::Kind(kind) => {
282 if !supported.contains(kind) {
283 return Err(Error::NoOidcSupport(service));
284 }
285 Some(kind.clone())
286 }
287 };
288 if auth_kind.is_some() && cfg.auth.is_none() {
289 return Err(Error::AuthNotConfigured);
290 }
291
292 let enable_smtp = req.smtp.unwrap_or(cfg.smtp.is_some());
295 if enable_smtp && cfg.smtp.is_none() {
296 return Err(Error::ConfigValidation(format!(
297 "SMTP requested for '{service}' but no SMTP provider is configured \
298 (add inbucket, or configure SMTP first)"
299 )));
300 }
301
302 let requested_url = match &req.exposure {
307 ExposureRequest::Url(u) => Some(u.as_str()),
308 _ => None,
309 };
310 let needs_https = reg_service
311 .def
312 .service
313 .https
314 .needs_https(auth_kind.is_some(), requested_url);
315 let exposure = match &req.exposure {
316 ExposureRequest::Url(u) => Exposure::from_url(u),
317 ExposureRequest::Tailscale(u) => Exposure::Tailscale { url: u.clone() },
318 ExposureRequest::Loopback if needs_https => {
319 if crate::is_service_installed("caddy") {
320 let https_port = crate::well_known::caddy_https_port(&cfg);
321 let url = format!(
322 "https://{service}.{}:{https_port}",
323 config::schema::CADDY_LOCAL_DOMAIN
324 );
325 notes.push(format!("{service} requires HTTPS; exposing at {url}"));
326 Exposure::from_url(&url)
327 } else {
328 return Err(Error::ConfigValidation(format!(
329 "service '{service}' requires HTTPS but no exposure was given: \
330 pass a URL or tailscale exposure, or add caddy first"
331 )));
332 }
333 }
334 ExposureRequest::Loopback => Exposure::Loopback,
335 };
336
337 let auth_choice = match &auth_kind {
338 Some(kind) => AuthChoice::Native(kind.clone()),
339 None => AuthChoice::None,
340 };
341 let result = crate::add_service(AddServiceParams {
342 service_name: &service,
343 exposure: &exposure,
344 auth: auth_choice,
345 enable_smtp,
346 enable_backup: req.backup,
347 env_overrides: &req.env,
348 enabled_groups: &req.enable_groups,
349 selected_choices: &req.choose,
350 registry_name: service_ref.registry_name(),
351 repo_dir: &repo_dir,
352 pre_built_ctx: ctx.pre_built_ctx,
353 port_in_use: ctx.port_in_use,
354 acme_mode: ctx.acme,
355 mode: ctx.mode,
356 port_overrides: &ctx.port_overrides,
357 })?;
358
359 Ok(PlannedAdd {
360 registry_name: service_ref.registry_name().to_string(),
361 service,
362 result,
363 exposure,
364 auth_kind,
365 repo_dir,
366 notes,
367 })
368}
369
370pub fn plan_remove(req: &RemoveRequest) -> Result<RemoveResult> {
371 if crate::is_service_installed(&req.service) {
373 return crate::remove_service(&req.service, req.mode);
374 }
375 if matches!(req.mode, crate::RemoveMode::Purge)
381 && let Some(svc) = crate::data::enumerate_service(&req.service)?
382 {
383 return Ok(RemoveResult {
384 steps: crate::orphan_purge_steps(&svc),
385 service_name: req.service.clone(),
386 url: None,
387 });
388 }
389 crate::remove_service(&req.service, req.mode)
392}
393
394pub fn plan_lifecycle(req: &LifecycleRequest) -> Result<Vec<Step>> {
395 crate::lifecycle_steps(&req.service, req.action)
396}
397
398#[derive(Debug, Clone, Serialize, Deserialize)]
399pub struct BackupRunRequest {
400 pub service: String,
401}
402
403pub async fn plan_backup_run(req: &BackupRunRequest) -> Result<crate::backup::BackupRunPlan> {
407 let paths = config::ConfigPaths::resolve()?;
408 let cfg = config::load_or_default(&paths.config_file)?;
409 let installed = crate::list_installed()?
410 .into_iter()
411 .find(|s| s.name == req.service)
412 .ok_or_else(|| Error::ServiceNotInstalled(req.service.clone()))?;
413 let service_ref = crate::service_ref_from_installed(&installed);
414 let repo_dir = crate::resolve_registry_dir(&service_ref).await?;
415 crate::backup::plan_backup_run(&req.service, &cfg, &repo_dir)
416}