ryra_core/plan.rs
1//! Typed steps the CLI executes, the warnings it surfaces, and the result
2//! shapes returned from `add` / `remove` / `reset`. Pattern matching ensures
3//! every step type is handled — no string parsing or if-chains.
4
5use std::path::PathBuf;
6
7use crate::generate::GeneratedFile;
8
9/// One port served over a service's Tailscale vIP: TLS-terminated at
10/// `https_port` on the service hostname, proxied to `http://127.0.0.1:<host_port>`.
11/// The entry with `https_port == 443` answers at the bare hostname (web root).
12#[derive(Debug, Clone)]
13pub struct TailscalePort {
14 pub https_port: u16,
15 pub host_port: u16,
16}
17
18/// Resolve which ports a service exposes over its Tailscale vIP.
19///
20/// Ports declaring `tailscale_https` are each served on that HTTPS port,
21/// mapped to their resolved host port. A service that declares none (every
22/// single-port web app — seafile, authelia, …) falls back to serving its
23/// primary port at the web root (`443`), preserving the original behaviour.
24pub fn tailscale_ports(
25 ports: &[crate::registry::service_def::PortDef],
26 resolved: &[(String, u16)],
27 primary_host_port: Option<u16>,
28) -> Vec<TailscalePort> {
29 let mapped: Vec<TailscalePort> = ports
30 .iter()
31 .filter_map(|p| {
32 let https_port = p.tailscale_https?;
33 let host_port = resolved
34 .iter()
35 .find(|(n, _)| n == &p.name)
36 .map(|(_, hp)| *hp)
37 .or(p.host_port)?;
38 Some(TailscalePort {
39 https_port,
40 host_port,
41 })
42 })
43 .collect();
44 if !mapped.is_empty() {
45 return mapped;
46 }
47 primary_host_port
48 .map(|host_port| {
49 vec![TailscalePort {
50 https_port: 443,
51 host_port,
52 }]
53 })
54 .unwrap_or_default()
55}
56
57/// A discrete operation that the CLI executes.
58pub enum Step {
59 /// Write a file.
60 WriteFile(GeneratedFile),
61 /// Create a symlink at `link` pointing to `target`. Idempotent: if
62 /// `link` already exists (whether as a file, dir, or symlink), it's
63 /// removed first. Used to satisfy systemd's fixed quadlet path
64 /// (`~/.config/containers/systemd/<svc>.container`) while keeping
65 /// the real file alongside the rest of the service's data in
66 /// `~/.local/share/services/<svc>/`.
67 Symlink { link: PathBuf, target: PathBuf },
68 /// Reload systemd for the current user.
69 DaemonReload,
70 /// Start a service under the current user's systemd.
71 StartService { unit: String },
72 /// Stop a service under the current user's systemd.
73 StopService { unit: String },
74 /// Restart a service under the current user's systemd.
75 RestartService { unit: String },
76 /// Reload Caddy's config without restarting the container.
77 ReloadCaddy,
78 /// Pull a container image.
79 PullImage { image: String },
80 /// Remove a file.
81 RemoveFile(PathBuf),
82 /// Remove a directory tree.
83 RemoveDir(PathBuf),
84 /// Remove a podman named volume.
85 RemoveVolume { name: String },
86 /// Remove a podman network. Best-effort: skipped when the network is
87 /// still in use by another service (which is the correct outcome) or
88 /// already gone. `ryra remove` emits this after stopping a service's
89 /// `<svc>-network` unit, because stopping a `RemainAfterExit` network
90 /// oneshot leaves the podman network behind — and that leak makes the
91 /// next install fail (its regenerated network unit's `podman network
92 /// create` hits the existing network).
93 RemoveNetwork { name: String },
94 /// Create a directory (with parents).
95 CreateDir(PathBuf),
96 /// Wait for a file to appear (with timeout).
97 WaitForFile { path: PathBuf, timeout_secs: u32 },
98 /// Poll an HTTP endpoint until it answers with `expect_status`, or time
99 /// out. The readiness gate for a blue/green deploy: ryra won't swap the
100 /// Caddy upstream onto a freshly started instance until its health endpoint
101 /// says it's actually serving (DB up, migrations run). A timeout aborts the
102 /// deploy with the old instance still live and serving.
103 WaitForHttpHealthy {
104 url: String,
105 expect_status: u16,
106 timeout_secs: u32,
107 },
108 /// Copy a file from the registry (or similar source) to a destination.
109 /// Used for vendored binary files (e.g. Jellyfin's SSO plugin DLLs)
110 /// that don't fit the templated `configs/` pipeline.
111 CopyFile { src: PathBuf, dst: PathBuf },
112 /// Run a build/prepare command in `dir` (e.g. `cargo build --release`,
113 /// `bun install`) for a `runtime = "native"` service. Runs at apply time
114 /// in the service's source dir, before the unit is (re)started.
115 Build { dir: PathBuf, command: String },
116 /// Mirror a source tree into `dst` (clearing `dst` first), skipping
117 /// VCS/build/dependency dirs. The language-agnostic primitive behind native
118 /// blue/green: each color slot gets its own isolated working copy, so a
119 /// rebuild of the idle slot can't mutate source files the live slot is
120 /// still reading (critical for interpreted runtimes like Python/Node).
121 SyncDir { src: PathBuf, dst: PathBuf },
122 /// First-time Tailscale Services setup on this tailnet: ensure ACL
123 /// has `tag:ryra-host` + `tag:ryra-service` tagOwners and the
124 /// services autoApprover entry, then apply `tag:ryra-host` to the
125 /// local node so it's allowed to advertise services. Idempotent:
126 /// reads current state via API and only writes diffs.
127 TailscaleSetup,
128 /// Define a Tailscale Service via the admin API and advertise it
129 /// from the host: `sudo tailscale serve --service=svc:<svc_name>
130 /// --https=443 http://127.0.0.1:<host_port>`. The service gets
131 /// `tag:ryra-service` (matches the autoApprover) so the host's
132 /// advertisement auto-approves with no manual UI clicks.
133 ///
134 /// `svc_name` is the part after `svc:` — already host-scoped at
135 /// planning time (`<service>-<host>`) so two ryra hosts on the
136 /// same tailnet can run independent copies of a service without
137 /// colliding on the global Tailscale Service namespace.
138 TailscaleEnable {
139 svc_name: String,
140 ports: Vec<TailscalePort>,
141 },
142 /// Stop advertising a Tailscale Service on this host and delete
143 /// its definition via the admin API. Used in `ryra remove --purge`
144 /// and `ryra reset` for tailscale-enabled services. `svc_name`
145 /// matches the value used at install time (recovered from the
146 /// stored Tailscale URL so a hostname change post-install doesn't
147 /// break teardown).
148 TailscaleDisable { svc_name: String },
149}
150
151impl Step {
152 /// Render this step as a shell command (for dry-run display).
153 pub fn to_command(&self) -> String {
154 match self {
155 Step::WriteFile(file) => format!("write {}", file.path.display()),
156 Step::Symlink { link, target } => {
157 format!("ln -sf {} {}", target.display(), link.display())
158 }
159 Step::DaemonReload => "systemctl --user daemon-reload".into(),
160 Step::StartService { unit } => format!("systemctl --user start {unit}"),
161 Step::StopService { unit } => format!("systemctl --user stop {unit}"),
162 Step::RestartService { unit } => format!("systemctl --user restart {unit}"),
163 Step::ReloadCaddy => {
164 "podman exec caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile"
165 .into()
166 }
167 Step::PullImage { image } => format!("podman pull {image}"),
168 Step::RemoveFile(path) => format!("rm -f {}", path.display()),
169 Step::RemoveDir(path) => format!("rm -rf {}", path.display()),
170 Step::CreateDir(path) => format!("mkdir -p {}", path.display()),
171 Step::RemoveVolume { name } => format!("podman volume rm {name}"),
172 Step::RemoveNetwork { name } => format!("podman network rm {name}"),
173 Step::WaitForFile { path, timeout_secs } => {
174 format!("wait for {} (up to {timeout_secs}s)", path.display())
175 }
176 Step::WaitForHttpHealthy {
177 url,
178 expect_status,
179 timeout_secs,
180 } => format!("wait for {url} -> {expect_status} (up to {timeout_secs}s)"),
181 Step::CopyFile { src, dst } => format!("cp {} {}", src.display(), dst.display()),
182 Step::Build { dir, command } => format!("(cd {} && {command})", dir.display()),
183 Step::SyncDir { src, dst } => {
184 format!(
185 "sync {} -> {} (skip build/VCS dirs)",
186 src.display(),
187 dst.display()
188 )
189 }
190 Step::TailscaleSetup => "tailscale: ensure ACL tags + auto-approval".to_string(),
191 Step::TailscaleEnable { svc_name, ports } => ports
192 .iter()
193 .map(|p| {
194 format!(
195 "tailscale serve --service=svc:{svc_name} --https={} http://127.0.0.1:{}",
196 p.https_port, p.host_port
197 )
198 })
199 .collect::<Vec<_>>()
200 .join(" && "),
201 Step::TailscaleDisable { svc_name } => {
202 format!("tailscale serve --service=svc:{svc_name} off + delete service")
203 }
204 }
205 }
206}
207
208/// Warnings generated during service operations that the CLI should display.
209pub enum Warning {
210 /// System RAM is below the service's minimum requirement.
211 RamBelowMinimum {
212 service_name: String,
213 min_mb: u64,
214 available_mb: u64,
215 },
216 /// System RAM is below the service's recommended level (but above minimum).
217 RamBelowRecommended {
218 service_name: String,
219 recommended_mb: u64,
220 available_mb: u64,
221 },
222 /// A port was reassigned because the default was privileged or in use.
223 PortReassigned {
224 service_name: String,
225 port_name: String,
226 original_port: u16,
227 assigned_port: u16,
228 reason: String,
229 },
230 /// `--url` was passed but no ryra-managed reverse proxy (Caddy) is installed.
231 /// Ryra still templates the URL into env vars and OIDC config, but routing
232 /// is the user's responsibility (nginx, Cloudflare Tunnel, Tailscale Funnel,
233 /// external load balancer, etc.).
234 UrlWithoutReverseProxy {
235 service_name: String,
236 url: String,
237 host_port: u16,
238 },
239}
240
241pub struct AddResult {
242 pub steps: Vec<Step>,
243 pub warnings: Vec<Warning>,
244 pub repo_url: String,
245 /// Allocated ports for this service (port_name, host_port).
246 pub allocated_ports: Vec<(String, u16)>,
247 /// Names of auto-generated secrets (values are in .env).
248 pub generated_secrets: Vec<String>,
249 /// The generated .env content (for post-install processing).
250 pub env_content: String,
251 /// Public URL for this service (if --url was provided).
252 pub url: Option<String>,
253 /// Static env vars (key, default value, kind, optional human prompt
254 /// label) the registry expects in `.env`. Populated whether or not
255 /// the user is in interactive mode — `ryra upgrade` reads this back
256 /// to decide which additions need to prompt the user (kind=Prompted
257 /// / Required) versus which can be appended silently (kind=Default).
258 pub tracked_envs: Vec<TrackedEnv>,
259}
260
261/// Per-env metadata the planner keeps alongside the rendered value, so
262/// downstream callers (CLI prompts for `ryra upgrade`) can decide
263/// whether a given env var needs user input.
264#[derive(Debug, Clone)]
265pub struct TrackedEnv {
266 pub key: String,
267 pub value: String,
268 pub kind: crate::registry::service_def::EnvKind,
269 pub prompt: Option<String>,
270}
271
272pub struct RemoveResult {
273 pub steps: Vec<Step>,
274 pub service_name: String,
275 /// URL that was assigned to this service (if any).
276 pub url: Option<String>,
277}
278
279pub struct ResetResult {
280 pub steps: Vec<Step>,
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286 use crate::registry::service_def::{PortDef, PortProtocol};
287
288 fn port(name: &str, container: u16, ts: Option<u16>) -> PortDef {
289 PortDef {
290 name: name.into(),
291 container_port: container,
292 host_port: None,
293 protocol: PortProtocol::default(),
294 tailscale_https: ts,
295 }
296 }
297
298 #[test]
299 fn single_port_service_falls_back_to_primary_on_443() {
300 // No port declares tailscale_https → primary served at the web root.
301 let ports = vec![port("http", 80, None)];
302 let resolved = vec![("http".to_string(), 10001u16)];
303 let out = tailscale_ports(&ports, &resolved, Some(10001));
304 assert_eq!(out.len(), 1);
305 assert_eq!(out[0].https_port, 443);
306 assert_eq!(out[0].host_port, 10001);
307 }
308
309 #[test]
310 fn multiport_maps_each_declared_port_to_its_resolved_host_port() {
311 let ports = vec![
312 port("http", 8080, Some(8080)),
313 port("photos", 3000, Some(443)),
314 ];
315 let resolved = vec![
316 ("http".to_string(), 8080u16),
317 ("photos".to_string(), 10002u16),
318 ];
319 let mut out = tailscale_ports(&ports, &resolved, Some(8080));
320 out.sort_by_key(|p| p.https_port);
321 assert_eq!(out.len(), 2);
322 assert_eq!((out[0].https_port, out[0].host_port), (443, 10002)); // photos root
323 assert_eq!((out[1].https_port, out[1].host_port), (8080, 8080)); // museum api
324 }
325
326 #[test]
327 fn no_ports_and_no_primary_yields_empty() {
328 assert!(tailscale_ports(&[], &[], None).is_empty());
329 }
330}