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}
80
81impl AddRequest {
82 pub fn new(service: impl Into<String>) -> Self {
84 AddRequest {
85 service: service.into(),
86 exposure: ExposureRequest::default(),
87 auth: AuthRequested::default(),
88 smtp: None,
89 backup: false,
90 env: BTreeMap::new(),
91 enable_groups: BTreeSet::new(),
92 }
93 }
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct RemoveRequest {
98 pub service: String,
99 #[serde(default)]
100 pub mode: RemoveMode,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct LifecycleRequest {
105 pub service: String,
106 pub action: Lifecycle,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct UpgradeRequest {
111 pub service: String,
112 #[serde(default)]
115 pub force: bool,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct ConfigureRequest {
123 pub service: String,
124 pub changes: crate::configure::Overrides,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
131#[serde(tag = "op", rename_all = "snake_case")]
132pub enum Operation {
133 Add(AddRequest),
134 Remove(RemoveRequest),
135 Lifecycle(LifecycleRequest),
136 Upgrade(UpgradeRequest),
137 Configure(ConfigureRequest),
138 BackupRun(BackupRunRequest),
139}
140
141pub struct PlanContext<'a> {
145 pub port_in_use: &'a (dyn Fn(u16) -> bool + Sync),
148 pub resolved: Option<(&'a ServiceRef, &'a Path)>,
152 pub pre_built_ctx: Option<BTreeMap<String, String>>,
155 pub port_overrides: BTreeMap<String, u16>,
157 pub mode: PlanMode,
158 pub acme: Option<&'a crate::caddy::AcmeMode>,
162}
163
164impl<'a> PlanContext<'a> {
165 pub fn new(port_in_use: &'a (dyn Fn(u16) -> bool + Sync)) -> Self {
166 PlanContext {
167 port_in_use,
168 resolved: None,
169 pre_built_ctx: None,
170 port_overrides: BTreeMap::new(),
171 mode: PlanMode::Add,
172 acme: None,
173 }
174 }
175}
176
177pub struct PlannedAdd {
180 pub service: String,
182 pub result: AddResult,
183 pub exposure: Exposure,
184 pub auth_kind: Option<AuthKind>,
185 pub registry_name: String,
186 pub repo_dir: PathBuf,
187 pub notes: Vec<String>,
190}
191
192impl PlannedAdd {
193 pub fn record_pending(&self) -> Result<()> {
196 crate::record_pending(crate::RecordPendingParams {
197 service_name: &self.service,
198 auth_kind: self.auth_kind.clone(),
199 registry_name: &self.registry_name,
200 allocated_ports: &self.result.allocated_ports,
201 repo_dir: &self.repo_dir,
202 exposure: &self.exposure,
203 })
204 }
205}
206
207pub enum Planned {
210 Add(Box<PlannedAdd>),
211 Remove(RemoveResult),
212 Lifecycle(Vec<Step>),
213 Upgrade(Box<crate::upgrade::UpgradeResult>),
214 Configure(Box<crate::configure::ConfigureResult>),
215 BackupRun(Box<crate::backup::BackupRunPlan>),
216}
217
218pub async fn plan(op: &Operation, ctx: PlanContext<'_>) -> Result<Planned> {
220 match op {
221 Operation::Add(req) => Ok(Planned::Add(Box::new(plan_add(req, ctx).await?))),
222 Operation::Remove(req) => Ok(Planned::Remove(plan_remove(req)?)),
223 Operation::Lifecycle(req) => Ok(Planned::Lifecycle(plan_lifecycle(req)?)),
224 Operation::Upgrade(req) => Ok(Planned::Upgrade(Box::new(plan_upgrade(req).await?))),
225 Operation::Configure(req) => Ok(Planned::Configure(Box::new(plan_configure(req).await?))),
226 Operation::BackupRun(req) => Ok(Planned::BackupRun(Box::new(plan_backup_run(req).await?))),
227 }
228}
229
230pub async fn plan_upgrade(req: &UpgradeRequest) -> Result<crate::upgrade::UpgradeResult> {
231 crate::upgrade::upgrade_service(&req.service, req.force).await
232}
233
234pub async fn plan_configure(req: &ConfigureRequest) -> Result<crate::configure::ConfigureResult> {
235 crate::configure::configure_service(&req.service, &req.changes).await
236}
237
238pub async fn plan_add(req: &AddRequest, ctx: PlanContext<'_>) -> Result<PlannedAdd> {
239 let mut notes = Vec::new();
240
241 let (service_ref, repo_dir) = match ctx.resolved {
243 Some((r, d)) => (r.clone(), d.to_path_buf()),
244 None => {
245 let r = ServiceRef::parse(&req.service)?;
246 let d = crate::resolve_registry_dir(&r).await?;
247 (r, d)
248 }
249 };
250 let service = service_ref.service_name().to_string();
251 let reg_service = crate::registry::find_service(&repo_dir, &service)?;
252 let paths = config::ConfigPaths::resolve()?;
253 let cfg = config::load_or_default(&paths.config_file)?;
254
255 let supported = ®_service.def.integrations.auth;
259 let auth_kind: Option<AuthKind> = match &req.auth {
260 AuthRequested::No => None,
261 AuthRequested::Yes => match supported.first() {
262 Some(kind) => Some(kind.clone()),
263 None if reg_service
264 .def
265 .capabilities
266 .provides
267 .contains(&Capability::OidcProvider) =>
268 {
269 notes.push(format!(
270 "{service} is the auth provider itself; auth has no effect"
271 ));
272 None
273 }
274 None => return Err(Error::NoOidcSupport(service)),
275 },
276 AuthRequested::Kind(kind) => {
277 if !supported.contains(kind) {
278 return Err(Error::NoOidcSupport(service));
279 }
280 Some(kind.clone())
281 }
282 };
283 if auth_kind.is_some() && cfg.auth.is_none() {
284 return Err(Error::AuthNotConfigured);
285 }
286
287 let enable_smtp = req.smtp.unwrap_or(cfg.smtp.is_some());
290 if enable_smtp && cfg.smtp.is_none() {
291 return Err(Error::ConfigValidation(format!(
292 "SMTP requested for '{service}' but no SMTP provider is configured \
293 (add inbucket, or configure SMTP first)"
294 )));
295 }
296
297 let requested_url = match &req.exposure {
302 ExposureRequest::Url(u) => Some(u.as_str()),
303 _ => None,
304 };
305 let needs_https = reg_service
306 .def
307 .service
308 .https
309 .needs_https(auth_kind.is_some(), requested_url);
310 let exposure = match &req.exposure {
311 ExposureRequest::Url(u) => Exposure::from_url(u),
312 ExposureRequest::Tailscale(u) => Exposure::Tailscale { url: u.clone() },
313 ExposureRequest::Loopback if needs_https => {
314 if crate::is_service_installed("caddy") {
315 let https_port = crate::well_known::caddy_https_port(&cfg);
316 let url = format!(
317 "https://{service}.{}:{https_port}",
318 config::schema::CADDY_LOCAL_DOMAIN
319 );
320 notes.push(format!("{service} requires HTTPS; exposing at {url}"));
321 Exposure::from_url(&url)
322 } else {
323 return Err(Error::ConfigValidation(format!(
324 "service '{service}' requires HTTPS but no exposure was given: \
325 pass a URL or tailscale exposure, or add caddy first"
326 )));
327 }
328 }
329 ExposureRequest::Loopback => Exposure::Loopback,
330 };
331
332 let auth_choice = match &auth_kind {
333 Some(kind) => AuthChoice::Native(kind.clone()),
334 None => AuthChoice::None,
335 };
336 let result = crate::add_service(AddServiceParams {
337 service_name: &service,
338 exposure: &exposure,
339 auth: auth_choice,
340 enable_smtp,
341 enable_backup: req.backup,
342 env_overrides: &req.env,
343 enabled_groups: &req.enable_groups,
344 registry_name: service_ref.registry_name(),
345 repo_dir: &repo_dir,
346 pre_built_ctx: ctx.pre_built_ctx,
347 port_in_use: ctx.port_in_use,
348 acme_mode: ctx.acme,
349 mode: ctx.mode,
350 port_overrides: &ctx.port_overrides,
351 })?;
352
353 Ok(PlannedAdd {
354 registry_name: service_ref.registry_name().to_string(),
355 service,
356 result,
357 exposure,
358 auth_kind,
359 repo_dir,
360 notes,
361 })
362}
363
364pub fn plan_remove(req: &RemoveRequest) -> Result<RemoveResult> {
365 crate::remove_service(&req.service, req.mode)
366}
367
368pub fn plan_lifecycle(req: &LifecycleRequest) -> Result<Vec<Step>> {
369 crate::lifecycle_steps(&req.service, req.action)
370}
371
372#[derive(Debug, Clone, Serialize, Deserialize)]
373pub struct BackupRunRequest {
374 pub service: String,
375}
376
377pub async fn plan_backup_run(req: &BackupRunRequest) -> Result<crate::backup::BackupRunPlan> {
381 let paths = config::ConfigPaths::resolve()?;
382 let cfg = config::load_or_default(&paths.config_file)?;
383 let installed = crate::list_installed()?
384 .into_iter()
385 .find(|s| s.name == req.service)
386 .ok_or_else(|| Error::ServiceNotInstalled(req.service.clone()))?;
387 let service_ref = crate::service_ref_from_installed(&installed);
388 let repo_dir = crate::resolve_registry_dir(&service_ref).await?;
389 crate::backup::plan_backup_run(&req.service, &cfg, &repo_dir)
390}