Skip to main content

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