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 #[serde(default)]
88 pub allow_unset_required: bool,
89}
90
91impl AddRequest {
92 pub fn new(service: impl Into<String>) -> Self {
94 AddRequest {
95 service: service.into(),
96 exposure: ExposureRequest::default(),
97 auth: AuthRequested::default(),
98 smtp: None,
99 backup: false,
100 env: BTreeMap::new(),
101 enable_groups: BTreeSet::new(),
102 choose: BTreeMap::new(),
103 allow_unset_required: false,
104 }
105 }
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct RemoveRequest {
110 pub service: String,
111 #[serde(default)]
112 pub mode: RemoveMode,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct LifecycleRequest {
117 pub service: String,
118 pub action: Lifecycle,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct UpgradeRequest {
123 pub service: String,
124 #[serde(default)]
127 pub force: bool,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct ConfigureRequest {
135 pub service: String,
136 pub changes: crate::configure::Overrides,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
143#[serde(tag = "op", rename_all = "snake_case")]
144pub enum Operation {
145 Add(AddRequest),
146 Remove(RemoveRequest),
147 Lifecycle(LifecycleRequest),
148 Upgrade(UpgradeRequest),
149 Configure(ConfigureRequest),
150 BackupRun(BackupRunRequest),
151}
152
153pub struct PlanContext<'a> {
157 pub port_in_use: &'a (dyn Fn(u16) -> bool + Sync),
160 pub resolved: Option<(&'a ServiceRef, &'a Path)>,
164 pub pre_built_ctx: Option<BTreeMap<String, String>>,
167 pub port_overrides: BTreeMap<String, u16>,
169 pub mode: PlanMode,
170 pub acme: Option<&'a crate::caddy::AcmeMode>,
174 pub existing_env: Option<String>,
180}
181
182impl<'a> PlanContext<'a> {
183 pub fn new(port_in_use: &'a (dyn Fn(u16) -> bool + Sync)) -> Self {
184 PlanContext {
185 port_in_use,
186 resolved: None,
187 pre_built_ctx: None,
188 port_overrides: BTreeMap::new(),
189 mode: PlanMode::Add,
190 acme: None,
191 existing_env: None,
192 }
193 }
194}
195
196pub struct PlannedAdd {
199 pub service: String,
201 pub result: AddResult,
202 pub exposure: Exposure,
203 pub auth_kind: Option<AuthKind>,
204 pub registry_name: String,
205 pub repo_dir: PathBuf,
206 pub notes: Vec<String>,
209}
210
211impl PlannedAdd {
212 pub fn record_pending(&self) -> Result<()> {
215 crate::record_pending(crate::RecordPendingParams {
216 service_name: &self.service,
217 auth_kind: self.auth_kind.clone(),
218 registry_name: &self.registry_name,
219 allocated_ports: &self.result.allocated_ports,
220 repo_dir: &self.repo_dir,
221 exposure: &self.exposure,
222 })
223 }
224}
225
226pub enum Planned {
229 Add(Box<PlannedAdd>),
230 Remove(RemoveResult),
231 Lifecycle(Vec<Step>),
232 Upgrade(Box<crate::upgrade::UpgradeResult>),
233 Configure(Box<crate::configure::ConfigureResult>),
234 BackupRun(Box<crate::backup::BackupRunPlan>),
235}
236
237pub async fn plan(op: &Operation, ctx: PlanContext<'_>) -> Result<Planned> {
239 match op {
240 Operation::Add(req) => Ok(Planned::Add(Box::new(plan_add(req, ctx).await?))),
241 Operation::Remove(req) => Ok(Planned::Remove(plan_remove(req)?)),
242 Operation::Lifecycle(req) => Ok(Planned::Lifecycle(plan_lifecycle(req)?)),
243 Operation::Upgrade(req) => Ok(Planned::Upgrade(Box::new(plan_upgrade(req).await?))),
244 Operation::Configure(req) => Ok(Planned::Configure(Box::new(plan_configure(req).await?))),
245 Operation::BackupRun(req) => Ok(Planned::BackupRun(Box::new(plan_backup_run(req).await?))),
246 }
247}
248
249pub async fn plan_upgrade(req: &UpgradeRequest) -> Result<crate::upgrade::UpgradeResult> {
250 crate::upgrade::upgrade_service(&req.service, req.force).await
251}
252
253pub async fn plan_configure(req: &ConfigureRequest) -> Result<crate::configure::ConfigureResult> {
254 crate::configure::configure_service(&req.service, &req.changes).await
255}
256
257pub async fn plan_add(req: &AddRequest, ctx: PlanContext<'_>) -> Result<PlannedAdd> {
258 let mut notes = Vec::new();
259
260 let (service_ref, repo_dir) = match ctx.resolved {
262 Some((r, d)) => (r.clone(), d.to_path_buf()),
263 None => {
264 let r = ServiceRef::parse(&req.service)?;
265 let d = crate::resolve_registry_dir(&r).await?;
266 (r, d)
267 }
268 };
269 let service = service_ref.service_name().to_string();
270 let reg_service = crate::registry::find_service(&repo_dir, &service)?;
271 let paths = config::ConfigPaths::resolve()?;
272 let cfg = config::load_or_default(&paths.config_file)?;
273
274 let supported = ®_service.def.integrations.auth;
278 let auth_kind: Option<AuthKind> = match &req.auth {
279 AuthRequested::No => None,
280 AuthRequested::Yes => match supported.first() {
281 Some(kind) => Some(kind.clone()),
282 None if reg_service
283 .def
284 .capabilities
285 .provides
286 .contains(&Capability::OidcProvider) =>
287 {
288 notes.push(format!(
289 "{service} is the auth provider itself; auth has no effect"
290 ));
291 None
292 }
293 None => return Err(Error::NoOidcSupport(service)),
294 },
295 AuthRequested::Kind(kind) => {
296 if !supported.contains(kind) {
297 return Err(Error::NoOidcSupport(service));
298 }
299 Some(kind.clone())
300 }
301 };
302 if auth_kind.is_some() && cfg.auth.is_none() {
303 return Err(Error::AuthNotConfigured);
304 }
305
306 let enable_smtp = req.smtp.unwrap_or(cfg.smtp.is_some());
309 if enable_smtp && cfg.smtp.is_none() {
310 return Err(Error::ConfigValidation(format!(
311 "SMTP requested for '{service}' but no SMTP provider is configured \
312 (add inbucket, or configure SMTP first)"
313 )));
314 }
315
316 let requested_url = match &req.exposure {
321 ExposureRequest::Url(u) => Some(u.as_str()),
322 _ => None,
323 };
324 let needs_https = reg_service
325 .def
326 .service
327 .https
328 .needs_https(auth_kind.is_some(), requested_url);
329 let exposure = match &req.exposure {
330 ExposureRequest::Url(u) => Exposure::from_url(u),
331 ExposureRequest::Tailscale(u) => Exposure::Tailscale { url: u.clone() },
332 ExposureRequest::Loopback if needs_https => {
333 if crate::is_service_installed("caddy") {
334 let https_port = crate::well_known::caddy_https_port(&cfg);
335 let url = format!(
336 "https://{service}.{}:{https_port}",
337 config::schema::CADDY_LOCAL_DOMAIN
338 );
339 notes.push(format!("{service} requires HTTPS; exposing at {url}"));
340 Exposure::from_url(&url)
341 } else {
342 return Err(Error::ConfigValidation(format!(
343 "service '{service}' requires HTTPS but no exposure was given: \
344 pass a URL or tailscale exposure, or add caddy first"
345 )));
346 }
347 }
348 ExposureRequest::Loopback => Exposure::Loopback,
349 };
350
351 let auth_choice = match &auth_kind {
352 Some(kind) => AuthChoice::Native(kind.clone()),
353 None => AuthChoice::None,
354 };
355 let result = crate::add_service(AddServiceParams {
356 service_name: &service,
357 exposure: &exposure,
358 auth: auth_choice,
359 enable_smtp,
360 enable_backup: req.backup,
361 env_overrides: &req.env,
362 enabled_groups: &req.enable_groups,
363 selected_choices: &req.choose,
364 registry_name: service_ref.registry_name(),
365 repo_dir: &repo_dir,
366 pre_built_ctx: ctx.pre_built_ctx,
367 port_in_use: ctx.port_in_use,
368 acme_mode: ctx.acme,
369 mode: ctx.mode,
370 port_overrides: &ctx.port_overrides,
371 existing_env_file: ctx.existing_env,
372 allow_unset_required: req.allow_unset_required,
373 })?;
374
375 Ok(PlannedAdd {
376 registry_name: service_ref.registry_name().to_string(),
377 service,
378 result,
379 exposure,
380 auth_kind,
381 repo_dir,
382 notes,
383 })
384}
385
386pub fn plan_remove(req: &RemoveRequest) -> Result<RemoveResult> {
387 if crate::is_service_installed(&req.service) {
389 return crate::remove_service(&req.service, req.mode);
390 }
391 if matches!(req.mode, crate::RemoveMode::Purge)
397 && let Some(svc) = crate::data::enumerate_service(&req.service)?
398 {
399 return Ok(RemoveResult {
400 steps: crate::orphan_purge_steps(&svc),
401 service_name: req.service.clone(),
402 url: None,
403 });
404 }
405 crate::remove_service(&req.service, req.mode)
408}
409
410pub fn plan_lifecycle(req: &LifecycleRequest) -> Result<Vec<Step>> {
411 crate::lifecycle_steps(&req.service, req.action)
412}
413
414#[derive(Debug, Clone, Serialize, Deserialize)]
415pub struct BackupRunRequest {
416 pub service: String,
417 #[serde(default = "default_backup_mode")]
420 pub mode: String,
421}
422
423fn default_backup_mode() -> String {
424 "manual".to_string()
425}
426
427pub async fn plan_backup_run(req: &BackupRunRequest) -> Result<crate::backup::BackupRunPlan> {
431 let paths = config::ConfigPaths::resolve()?;
432 let cfg = config::load_or_default(&paths.config_file)?;
433 let installed = crate::list_installed()?
434 .into_iter()
435 .find(|s| s.name == req.service)
436 .ok_or_else(|| Error::ServiceNotInstalled(req.service.clone()))?;
437 let service_ref = crate::service_ref_from_installed(&installed);
438 let repo_dir = crate::resolve_registry_dir(&service_ref).await?;
439 crate::backup::plan_backup_run(&req.service, &cfg, &repo_dir, &req.mode)
440}