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/// A discrete operation that the CLI executes.
10pub enum Step {
11 /// Write a file.
12 WriteFile(GeneratedFile),
13 /// Create a symlink at `link` pointing to `target`. Idempotent: if
14 /// `link` already exists (whether as a file, dir, or symlink), it's
15 /// removed first. Used to satisfy systemd's fixed quadlet path
16 /// (`~/.config/containers/systemd/<svc>.container`) while keeping
17 /// the real file alongside the rest of the service's data in
18 /// `~/.local/share/services/<svc>/`.
19 Symlink { link: PathBuf, target: PathBuf },
20 /// Reload systemd for the current user.
21 DaemonReload,
22 /// Start a service under the current user's systemd.
23 StartService { unit: String },
24 /// Stop a service under the current user's systemd.
25 StopService { unit: String },
26 /// Restart a service under the current user's systemd.
27 RestartService { unit: String },
28 /// Reload Caddy's config without restarting the container.
29 ReloadCaddy,
30 /// Pull a container image.
31 PullImage { image: String },
32 /// Remove a file.
33 RemoveFile(PathBuf),
34 /// Remove a directory tree.
35 RemoveDir(PathBuf),
36 /// Remove a podman named volume.
37 RemoveVolume { name: String },
38 /// Create a directory (with parents).
39 CreateDir(PathBuf),
40 /// Wait for a file to appear (with timeout).
41 WaitForFile { path: PathBuf, timeout_secs: u32 },
42 /// Copy a file from the registry (or similar source) to a destination.
43 /// Used for vendored binary files (e.g. Jellyfin's SSO plugin DLLs)
44 /// that don't fit the templated `configs/` pipeline.
45 CopyFile { src: PathBuf, dst: PathBuf },
46 /// First-time Tailscale Services setup on this tailnet: ensure ACL
47 /// has `tag:ryra-host` + `tag:ryra-service` tagOwners and the
48 /// services autoApprover entry, then apply `tag:ryra-host` to the
49 /// local node so it's allowed to advertise services. Idempotent:
50 /// reads current state via API and only writes diffs.
51 TailscaleSetup,
52 /// Define a Tailscale Service via the admin API and advertise it
53 /// from the host: `sudo tailscale serve --service=svc:<svc_name>
54 /// --https=443 http://127.0.0.1:<host_port>`. The service gets
55 /// `tag:ryra-service` (matches the autoApprover) so the host's
56 /// advertisement auto-approves with no manual UI clicks.
57 ///
58 /// `svc_name` is the part after `svc:` — already host-scoped at
59 /// planning time (`<service>-<host>`) so two ryra hosts on the
60 /// same tailnet can run independent copies of a service without
61 /// colliding on the global Tailscale Service namespace.
62 TailscaleEnable { svc_name: String, host_port: u16 },
63 /// Stop advertising a Tailscale Service on this host and delete
64 /// its definition via the admin API. Used in `ryra remove --purge`
65 /// and `ryra reset` for tailscale-enabled services. `svc_name`
66 /// matches the value used at install time (recovered from the
67 /// stored Tailscale URL so a hostname change post-install doesn't
68 /// break teardown).
69 TailscaleDisable { svc_name: String },
70}
71
72impl Step {
73 /// Render this step as a shell command (for dry-run display).
74 pub fn to_command(&self) -> String {
75 match self {
76 Step::WriteFile(file) => format!("write {}", file.path.display()),
77 Step::Symlink { link, target } => {
78 format!("ln -sf {} {}", target.display(), link.display())
79 }
80 Step::DaemonReload => "systemctl --user daemon-reload".into(),
81 Step::StartService { unit } => format!("systemctl --user start {unit}"),
82 Step::StopService { unit } => format!("systemctl --user stop {unit}"),
83 Step::RestartService { unit } => format!("systemctl --user restart {unit}"),
84 Step::ReloadCaddy => {
85 "podman exec caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile"
86 .into()
87 }
88 Step::PullImage { image } => format!("podman pull {image}"),
89 Step::RemoveFile(path) => format!("rm -f {}", path.display()),
90 Step::RemoveDir(path) => format!("rm -rf {}", path.display()),
91 Step::CreateDir(path) => format!("mkdir -p {}", path.display()),
92 Step::RemoveVolume { name } => format!("podman volume rm {name}"),
93 Step::WaitForFile { path, timeout_secs } => {
94 format!("wait for {} (up to {timeout_secs}s)", path.display())
95 }
96 Step::CopyFile { src, dst } => format!("cp {} {}", src.display(), dst.display()),
97 Step::TailscaleSetup => "tailscale: ensure ACL tags + auto-approval".to_string(),
98 Step::TailscaleEnable {
99 svc_name,
100 host_port,
101 } => format!(
102 "tailscale serve --service=svc:{svc_name} --https=443 http://127.0.0.1:{host_port}"
103 ),
104 Step::TailscaleDisable { svc_name } => {
105 format!("tailscale serve --service=svc:{svc_name} off + delete service")
106 }
107 }
108 }
109}
110
111/// Warnings generated during service operations that the CLI should display.
112pub enum Warning {
113 /// System RAM is below the service's minimum requirement.
114 RamBelowMinimum {
115 service_name: String,
116 min_mb: u64,
117 available_mb: u64,
118 },
119 /// System RAM is below the service's recommended level (but above minimum).
120 RamBelowRecommended {
121 service_name: String,
122 recommended_mb: u64,
123 available_mb: u64,
124 },
125 /// A port was reassigned because the default was privileged or in use.
126 PortReassigned {
127 service_name: String,
128 port_name: String,
129 original_port: u16,
130 assigned_port: u16,
131 reason: String,
132 },
133 /// `--url` was passed but no bundled reverse proxy (Caddy) is installed.
134 /// Ryra still templates the URL into env vars and OIDC config, but routing
135 /// is the user's responsibility (nginx, Cloudflare Tunnel, Tailscale Funnel,
136 /// external load balancer, etc.).
137 UrlWithoutReverseProxy {
138 service_name: String,
139 url: String,
140 host_port: u16,
141 },
142}
143
144pub struct AddResult {
145 pub steps: Vec<Step>,
146 pub warnings: Vec<Warning>,
147 pub repo_url: String,
148 /// Allocated ports for this service (port_name, host_port).
149 pub allocated_ports: Vec<(String, u16)>,
150 /// Names of auto-generated secrets (values are in .env).
151 pub generated_secrets: Vec<String>,
152 /// The generated .env content (for post-install processing).
153 pub env_content: String,
154 /// Public URL for this service (if --url was provided).
155 pub url: Option<String>,
156 /// Static env vars (key, default value, kind, optional human prompt
157 /// label) the registry expects in `.env`. Populated whether or not
158 /// the user is in interactive mode — `ryra upgrade` reads this back
159 /// to decide which additions need to prompt the user (kind=Prompted
160 /// / Required) versus which can be appended silently (kind=Default).
161 pub tracked_envs: Vec<TrackedEnv>,
162}
163
164/// Per-env metadata the planner keeps alongside the rendered value, so
165/// downstream callers (CLI prompts for `ryra upgrade`) can decide
166/// whether a given env var needs user input.
167#[derive(Debug, Clone)]
168pub struct TrackedEnv {
169 pub key: String,
170 pub value: String,
171 pub kind: crate::registry::service_def::EnvKind,
172 pub prompt: Option<String>,
173}
174
175pub struct RemoveResult {
176 pub steps: Vec<Step>,
177 pub service_name: String,
178 /// URL that was assigned to this service (if any).
179 pub url: Option<String>,
180}
181
182pub struct ResetResult {
183 pub steps: Vec<Step>,
184}