Skip to main content

ryra_core/registry/
service_def.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::capability::Capability;
6
7/// A service definition from a registry's `services/<name>/service.toml`.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct ServiceDef {
10    pub service: ServiceMeta,
11    #[serde(default)]
12    pub requirements: Option<Requirements>,
13    #[serde(default)]
14    pub ports: Vec<PortDef>,
15    #[serde(default)]
16    pub env: Vec<EnvVar>,
17    /// Optional, user-toggled bundles of env vars. A group is either fully
18    /// enabled (every member lands in `.env`) or fully disabled (none do) —
19    /// makes "client_id without client_secret" unrepresentable.
20    #[serde(default, rename = "env_group")]
21    pub env_groups: Vec<EnvGroup>,
22    /// Mutually-exclusive choices. Exactly one option per choice is selected
23    /// and only that option's env vars are written, so "none selected" and
24    /// "two at once" are unrepresentable rather than rejected. The sum type
25    /// to `env_group`'s product.
26    #[serde(default, rename = "choice")]
27    pub choices: Vec<Choice>,
28    #[serde(default)]
29    pub requires: Vec<ServiceRequirement>,
30    #[serde(default)]
31    pub mappings: Mappings,
32    #[serde(default)]
33    pub integrations: IntegrationFlags,
34    /// Roles this service can play for *other* services. The dual of
35    /// [`IntegrationFlags`] (which describes what this service consumes).
36    /// Drives capability-based dispatch — see [`crate::capability`].
37    #[serde(default)]
38    pub capabilities: Capabilities,
39    /// Backup configuration. Present only when the author has declared
40    /// `backup = true` in `[integrations]` and the service needs more
41    /// than the default "back up everything classified as data."
42    /// Carries hooks (pre/post dump) and exclude lists.
43    #[serde(default)]
44    pub backup: Option<BackupConfig>,
45    /// Prometheus-style metrics endpoint this service exposes. When set
46    /// and a metrics-store provider is installed, ryra writes a file_sd
47    /// scrape target and joins the service to the store's network.
48    #[serde(default)]
49    pub metrics: Option<MetricsDef>,
50}
51
52/// Where a service serves Prometheus-style metrics.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct MetricsDef {
55    /// Name of the `[[ports]]` entry the metrics endpoint listens on.
56    /// The scrape target uses that entry's *container* port — the store
57    /// reaches the service over the shared podman network, not the host.
58    pub port: String,
59    /// HTTP path of the endpoint.
60    #[serde(default = "default_metrics_path")]
61    pub path: String,
62    /// The service runs with `Network=host` (e.g. node-exporter, which
63    /// needs the real interfaces). It can't join the store's bridge
64    /// network, so the scrape target addresses the podman host gateway
65    /// (`host.containers.internal`) at the *resolved host port* instead
66    /// of container DNS.
67    #[serde(default)]
68    pub host_network: bool,
69}
70
71fn default_metrics_path() -> String {
72    "/metrics".to_string()
73}
74
75/// Capability declarations on a service.
76#[derive(Debug, Clone, Default, Serialize, Deserialize)]
77pub struct Capabilities {
78    /// Capabilities this service offers to other services.
79    #[serde(default)]
80    pub provides: Vec<Capability>,
81}
82
83/// System resource requirements for a service.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct Requirements {
86    /// RAM requirements in megabytes.
87    pub ram: RamRequirement,
88    /// Disk requirements in gigabytes.
89    #[serde(default)]
90    pub disk: Option<DiskRequirement>,
91}
92
93/// RAM requirement with minimum and recommended thresholds.
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct RamRequirement {
96    /// Minimum RAM in MB — service may fail below this.
97    pub min: u64,
98    /// Recommended RAM in MB — service will run well at this level.
99    #[serde(default)]
100    pub recommended: Option<u64>,
101}
102
103/// Disk requirement in gigabytes.
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct DiskRequirement {
106    /// Minimum disk in GB — container images + data must fit.
107    pub min: u32,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct ServiceMeta {
112    pub name: String,
113    pub description: String,
114    /// Optional URL to documentation or project homepage.
115    #[serde(default)]
116    pub url: Option<String>,
117    #[serde(default)]
118    pub kind: ServiceKind,
119    /// Supported CPU architectures (e.g. ["amd64", "arm64"]).
120    /// Empty means all architectures are supported.
121    #[serde(default)]
122    pub architecture: Vec<Arch>,
123    /// Whether this service requires HTTPS to function.
124    #[serde(default)]
125    pub https: HttpsRequirement,
126    /// How this service runs: a podman container (default) or a native process
127    /// under systemd --user.
128    #[serde(default)]
129    pub runtime: Runtime,
130    /// `runtime = "native"` only: the command ryra runs as the service (the
131    /// unit's `ExecStart`), executed in the service's source dir. A binary
132    /// (`target/release/app`), an interpreter (`bun run src/index.ts`), or a
133    /// watcher (`bun --watch run …`) for save-and-reload. Required for native,
134    /// forbidden for podman (enforced in `validate()`).
135    #[serde(default)]
136    pub run: Option<String>,
137    /// `runtime = "native"` only: optional command run in the source dir before
138    /// the service starts and on every `ryra upgrade` (e.g. `cargo build
139    /// --release`, `bun install`). Omit when `run` needs no build step.
140    #[serde(default)]
141    pub build: Option<String>,
142    /// Free-text guidance printed once after a successful `ryra add` —
143    /// truly-unavoidable manual steps (initial web wizard, recommended
144    /// dashboard imports). Keep it short; everything automatable should
145    /// be automated instead.
146    #[serde(default)]
147    pub post_install: Option<String>,
148    /// How `ryra upgrade` cuts a new version over. `restart` (default) stops
149    /// the old instance before starting the new; `blue-green` runs both and
150    /// swaps traffic for a zero-downtime deploy. See [`DeployStrategy`].
151    #[serde(default, skip_serializing_if = "DeployStrategy::is_restart")]
152    pub deploy: DeployStrategy,
153    /// `deploy = "blue-green"` only: the HTTP path ryra polls on a freshly
154    /// started instance to decide it's live before swapping traffic onto it
155    /// (e.g. `/healthz`). Required for blue/green; ignored otherwise. The
156    /// endpoint should return 200 only once the process is actually ready to
157    /// serve (DB reachable, migrations run).
158    #[serde(default)]
159    pub health_check: Option<String>,
160    /// `deploy = "blue-green"` only: how many seconds to wait for the idle
161    /// slot's `health_check` to pass before aborting the deploy (leaving the
162    /// old slot live). Optional — omit it and ryra uses
163    /// [`DEFAULT_HEALTH_TIMEOUT_SECS`]. Bump it for services with a slow cold
164    /// start (big migration, JIT warmup). Read via [`ServiceMeta::health_timeout_secs`].
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub health_timeout: Option<u32>,
167}
168
169/// Default seconds to wait for a blue/green slot's health check before aborting
170/// the deploy. Generous enough to cover a normal cold start + migrations;
171/// services that need longer set `health_timeout` in their service.toml.
172pub const DEFAULT_HEALTH_TIMEOUT_SECS: u32 = 120;
173
174impl ServiceMeta {
175    /// The blue/green health-gate timeout in seconds: the author's
176    /// `health_timeout` if set, else [`DEFAULT_HEALTH_TIMEOUT_SECS`]. Callers
177    /// never branch on "was it specified" — they just ask for the value.
178    pub fn health_timeout_secs(&self) -> u32 {
179        self.health_timeout.unwrap_or(DEFAULT_HEALTH_TIMEOUT_SECS)
180    }
181}
182
183/// What role this service plays in the system.
184#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
185#[serde(rename_all = "lowercase")]
186pub enum ServiceKind {
187    #[default]
188    Application,
189    Infrastructure,
190}
191
192/// How a service is realized on the host.
193#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
194#[serde(rename_all = "lowercase")]
195pub enum Runtime {
196    /// A rootless podman container via a quadlet (`Image=`). The default, and
197    /// what every catalog service uses.
198    #[default]
199    Podman,
200    /// A process run directly under `systemd --user`, no container. ryra runs
201    /// the service's `run` command in its source dir (after the optional
202    /// `build` step), with the same port/data/env contract a container gets.
203    Native,
204}
205
206impl Runtime {
207    /// Whether this is the default podman runtime. Used as a serde
208    /// `skip_serializing_if` so podman installs don't carry a redundant
209    /// `runtime = "podman"` in their metadata.
210    pub fn is_podman(&self) -> bool {
211        matches!(self, Runtime::Podman)
212    }
213}
214
215/// How `ryra upgrade` rolls a new version onto the host.
216///
217/// Orthogonal to [`Runtime`]: a strategy describes the *cutover*, the runtime
218/// describes the *instance*. Both native and podman services can be deployed
219/// either way.
220#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
221#[serde(rename_all = "kebab-case")]
222pub enum DeployStrategy {
223    /// Stop the running instance, then start the new one. A brief gap while the
224    /// new process starts, runs migrations, and binds its port. The default,
225    /// and what every service did before blue/green existed.
226    #[default]
227    Restart,
228    /// Start the new version *alongside* the old one on a second port,
229    /// health-check it, swap the Caddy upstream over (a graceful reload, no
230    /// dropped connections), then stop the old one. Zero-downtime, and because
231    /// the old instance lingers through the drain, rollback is an instant
232    /// upstream swap back — no rebuild. Requires an HTTP port, a Caddy-backed
233    /// exposure, and a `health_check` path.
234    BlueGreen,
235}
236
237impl DeployStrategy {
238    /// Whether this is the default restart strategy. Serde
239    /// `skip_serializing_if` hook so the common case carries no
240    /// `deploy = "restart"` line in metadata.
241    pub fn is_restart(&self) -> bool {
242        matches!(self, DeployStrategy::Restart)
243    }
244}
245
246/// Which of the two blue/green slots is currently live. Persisted in an
247/// install's metadata so the next deploy knows which slot to leave serving and
248/// which to roll the new version onto. Only meaningful for
249/// `deploy = "blue-green"` installs.
250#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
251#[serde(rename_all = "lowercase")]
252pub enum Color {
253    Blue,
254    Green,
255}
256
257impl Color {
258    /// The standby slot — the one a deploy rolls the new version onto.
259    pub fn other(self) -> Color {
260        match self {
261            Color::Blue => Color::Green,
262            Color::Green => Color::Blue,
263        }
264    }
265
266    /// Lowercase slug used in unit names, container names, and port keys
267    /// (`<svc>-blue`, `SERVICE_PORT_HTTP_GREEN`).
268    pub fn as_str(self) -> &'static str {
269        match self {
270            Color::Blue => "blue",
271            Color::Green => "green",
272        }
273    }
274}
275
276impl std::fmt::Display for Color {
277    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
278        f.write_str(self.as_str())
279    }
280}
281
282/// CPU architecture for container images.
283#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
284#[serde(rename_all = "lowercase")]
285pub enum Arch {
286    Amd64,
287    Arm64,
288}
289
290impl std::fmt::Display for Arch {
291    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
292        match self {
293            Arch::Amd64 => write!(f, "amd64"),
294            Arch::Arm64 => write!(f, "arm64"),
295        }
296    }
297}
298
299/// Whether this service requires HTTPS to function.
300///
301/// Declarative, per-service. No magic derivation from other fields — a
302/// service that needs HTTPS must say so explicitly.
303///
304/// - `Never` (default): HTTP is fine. Per RFC 8252 loopback redirect URIs
305///   (`http://127.0.0.1`, `http://localhost`) are valid OIDC callbacks, so
306///   most services work over plain HTTP even with `--auth`.
307/// - `Auth`: HTTPS required when `--auth` is used. For services whose OIDC
308///   implementation rejects plain-HTTP even on loopback (e.g. nextcloud's
309///   `user_oidc` refuses to render the SSO button over HTTP).
310/// - `Always`: HTTPS required regardless of flags. For services that
311///   refuse HTTP outright (e.g. authelia, vaultwarden).
312#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
313#[serde(rename_all = "lowercase")]
314pub enum HttpsRequirement {
315    #[default]
316    Never,
317    Auth,
318    Always,
319}
320
321impl HttpsRequirement {
322    /// Decide whether an install must be promoted to HTTPS.
323    ///
324    /// HTTPS is required when any of these hold:
325    ///   1. The service declares `https = "always"`.
326    ///   2. The service declares `https = "auth"` AND the user chose OIDC
327    ///      auth (via `--auth` or the interactive prompt).
328    ///   3. The user passed an `https://...` URL explicitly.
329    pub fn needs_https(&self, auth_requested: bool, url: Option<&str>) -> bool {
330        matches!(self, HttpsRequirement::Always)
331            || (matches!(self, HttpsRequirement::Auth) && auth_requested)
332            || url.is_some_and(|u| u.starts_with("https://"))
333    }
334}
335
336/// Whether a port uses TCP or UDP.
337#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
338#[serde(rename_all = "lowercase")]
339pub enum PortProtocol {
340    #[default]
341    Tcp,
342    Udp,
343}
344
345impl std::fmt::Display for PortProtocol {
346    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
347        match self {
348            PortProtocol::Tcp => write!(f, "tcp"),
349            PortProtocol::Udp => write!(f, "udp"),
350        }
351    }
352}
353
354#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct PortDef {
356    pub name: String,
357    pub container_port: u16,
358    /// Fixed host port (for privileged services like Caddy that need specific ports).
359    /// If not set, ryra allocates a port dynamically.
360    #[serde(default)]
361    pub host_port: Option<u16>,
362    #[serde(default)]
363    pub protocol: PortProtocol,
364    /// When set and the service is exposed with `--tailscale`, this port is
365    /// served over the service's Tailscale vIP on the given HTTPS port (e.g.
366    /// `443` for the web root, `8080` for an API). Tailnet-only `serve`
367    /// accepts arbitrary ports, so the value is usually the port's own number
368    /// (or `443` for the one port that should answer at the bare hostname).
369    /// Ports without this stay loopback-only. Reachable in templates via
370    /// `{{service.port_url.<name>}}`. Multi-port services (e.g. ente: a web
371    /// UI plus a separate API) need this so each endpoint gets its own URL.
372    #[serde(default)]
373    pub tailscale_https: Option<u16>,
374}
375
376/// How an env var is presented to the user during `ryra add`.
377///
378/// - `default`: static value or template (e.g. `{{secret.password}}`),
379///   not prompted — user can edit `.env` manually after install
380/// - `prompted`: shown during `ryra add` with a default value — optional
381///   but visible (e.g. API keys that can be left empty)
382/// - `required`: must be provided during `ryra add` — no usable default,
383///   blocks install if not provided. Tests must supply these via `env` overrides.
384#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
385#[serde(rename_all = "lowercase")]
386pub enum EnvKind {
387    /// Not prompted. Value is used as-is (may contain templates like `{{secret.*}}`).
388    #[default]
389    Default,
390    /// Prompted during `ryra add` with a default. User can accept or change.
391    Prompted,
392    /// Must be provided. No usable default — fails in non-interactive mode
393    /// unless supplied via env overrides.
394    Required,
395}
396
397/// Format of an env var's value — used for secret generation and input validation.
398#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
399#[serde(rename_all = "snake_case")]
400pub enum EnvFormat {
401    /// Free-form alphanumeric string (default).
402    #[default]
403    String,
404    /// Hexadecimal characters only.
405    Hex,
406    /// Standard base64 encoding of N random bytes (`length` = byte count,
407    /// default 32). Use for binary keys that the service base64-decodes to a
408    /// fixed byte length — e.g. Ente's libsodium keys (32-byte encryption,
409    /// 64-byte hash). A plain `string`/`hex` value decodes to the wrong length.
410    Base64,
411    /// URL-safe base64 (`-_` instead of `+/`) of N random bytes. Same use as
412    /// `base64`, but for services that decode with URL-safe base64 — e.g.
413    /// Ente's `jwt.secret` (Go `base64.URLEncoding`), which rejects `+`/`/`.
414    Base64Url,
415    /// UUID v4.
416    Uuid,
417    /// HS256-signed JWT. Requires `jwt_role` and `jwt_signing_key` on the env var.
418    JwtHs256,
419}
420
421#[derive(Debug, Clone, Serialize, Deserialize)]
422pub struct EnvVar {
423    pub name: String,
424    pub value: String,
425    #[serde(default)]
426    pub kind: EnvKind,
427    /// Prompt message shown during `ryra add` (for `prompted` and `required` kinds).
428    #[serde(default)]
429    pub prompt: Option<String>,
430    /// Value format — used to generate secrets and validate user input.
431    #[serde(default)]
432    pub format: EnvFormat,
433    /// Length for generated secrets. Ignored for `uuid` and `jwt_hs256` formats.
434    /// Defaults to 32 for `string`, 64 for `hex`.
435    #[serde(default)]
436    pub length: Option<u32>,
437    /// JSON payload claims for `jwt_hs256` format (e.g., `{"role": "anon", "iss": "supabase"}`).
438    /// `iat` and `exp` are added automatically if not present.
439    #[serde(default)]
440    pub jwt_claims: Option<std::collections::BTreeMap<std::string::String, serde_json::Value>>,
441    /// Secret name used as the HS256 signing key (e.g., "jwt_secret"). Required for `jwt_hs256` format.
442    #[serde(default)]
443    pub jwt_signing_key: Option<std::string::String>,
444}
445
446/// A user-toggled bundle of env vars. Enabling the group writes every
447/// member into `.env`; disabling it writes none of them.
448///
449/// Members reuse the full [`EnvVar`] shape — `kind = "default"` members are
450/// auto-included with their rendered template when the group is on,
451/// `prompted` members get shown with a default, `required` members must be
452/// supplied (interactively or via process env).
453#[derive(Debug, Clone, Serialize, Deserialize)]
454pub struct EnvGroup {
455    /// Identifier used by the `--enable <name>` CLI flag. Lowercase
456    /// snake_case by convention.
457    pub name: String,
458    /// Yes/no question shown during `ryra add` to toggle the group.
459    pub prompt: String,
460    #[serde(default)]
461    pub env: Vec<EnvVar>,
462}
463
464/// A mutually-exclusive choice between two or more [`ChoiceOption`]s. Where an
465/// [`EnvGroup`] is an independent on/off bundle (any subset may be enabled), a
466/// choice's selection is a single value, so the illegal states ("nothing
467/// selected", "two selected at once") cannot be constructed. This is the
468/// config-layer expression of "make invalid state unrepresentable": a sum
469/// type, the dual of `env_group`'s product.
470#[derive(Debug, Clone, Serialize, Deserialize)]
471pub struct Choice {
472    /// Identifier, lowercase snake_case. Names the `--choose <name>=<option>`
473    /// flag and the key recorded in metadata.
474    pub name: String,
475    /// Single-select question shown during `ryra add`.
476    pub prompt: String,
477    /// Option selected non-interactively (and pre-highlighted in the prompt).
478    /// Must name one of `options`; enforced by [`ServiceDef::validate`].
479    pub default: String,
480    #[serde(default, rename = "option")]
481    pub options: Vec<ChoiceOption>,
482}
483
484/// One alternative within a [`Choice`]. Its `env` members reuse the full
485/// [`EnvVar`] shape and are written to `.env` only when this option is the
486/// selected one; every other option's members stay absent.
487#[derive(Debug, Clone, Serialize, Deserialize)]
488pub struct ChoiceOption {
489    /// Identifier within the choice, lowercase snake_case.
490    pub name: String,
491    /// Human-facing text shown in the select list. Falls back to `name`.
492    #[serde(default)]
493    pub label: Option<String>,
494    #[serde(default)]
495    pub env: Vec<EnvVar>,
496    /// Owned sidecar quadlet filenames (in this service's own `quadlets/`
497    /// dir) included only when this option is selected. A quadlet claimed by
498    /// any option is gated; unclaimed quadlets always install. So `external`
499    /// claiming none means the bundled-DB `.container` is never symlinked and
500    /// its image never pulled, while `internal` brings it in. The kind-2
501    /// counterpart to `requires`' cross-service edges.
502    #[serde(default)]
503    pub quadlets: Vec<String>,
504    /// `[[ports]]` allocated only when this option is selected. ryra hands each
505    /// a free host port (exposed as `${SERVICE_PORT_<NAME>}` and
506    /// `{{service.ports.<name>}}`), so a gated container (e.g. a bundled
507    /// postgres) publishes to an allocated loopback port instead of a hardcoded
508    /// one that could clash.
509    #[serde(default, rename = "ports")]
510    pub ports: Vec<PortDef>,
511}
512
513/// A service that must already be installed on the system before this one.
514///
515/// References separately-installed ryra services whose env vars
516/// and ports can be referenced via `{{services.<name>.*}}` templates.
517#[derive(Debug, Clone, Serialize, Deserialize)]
518pub struct ServiceRequirement {
519    pub service: String,
520}
521
522#[derive(Debug, Clone, Default, Serialize, Deserialize)]
523pub struct Mappings {
524    #[serde(default)]
525    pub smtp: BTreeMap<String, String>,
526    #[serde(default)]
527    pub auth: BTreeMap<String, String>,
528}
529
530/// What kind of auth integration a service supports.
531#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
532#[serde(rename_all = "kebab-case")]
533pub enum AuthKind {
534    /// Service handles OIDC auth itself (e.g. affine, forgejo).
535    Oidc,
536}
537
538impl std::fmt::Display for AuthKind {
539    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
540        match self {
541            AuthKind::Oidc => write!(f, "oidc"),
542        }
543    }
544}
545
546/// OIDC token endpoint authentication method for authelia client registration.
547#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
548#[serde(rename_all = "snake_case")]
549pub enum TokenAuthMethod {
550    #[default]
551    ClientSecretPost,
552    ClientSecretBasic,
553    /// PKCE public client — no client_secret sent. Used by apps like Zammad
554    /// that only support the public-client + PKCE OIDC flow.
555    None,
556}
557
558impl TokenAuthMethod {
559    pub fn as_str(&self) -> &'static str {
560        match self {
561            TokenAuthMethod::ClientSecretPost => "client_secret_post",
562            TokenAuthMethod::ClientSecretBasic => "client_secret_basic",
563            TokenAuthMethod::None => "none",
564        }
565    }
566}
567
568#[derive(Debug, Clone, Serialize, Deserialize)]
569pub struct IntegrationFlags {
570    /// Auth types this service supports. Empty = no auth support.
571    #[serde(default)]
572    pub auth: Vec<AuthKind>,
573    /// OIDC token endpoint auth method for authelia client registration.
574    #[serde(default)]
575    pub token_auth_method: TokenAuthMethod,
576    /// OIDC callback path suffixes registered with the auth provider.
577    /// Appended to the service's base URL(s) to form redirect_uris.
578    #[serde(default)]
579    pub oidc_callbacks: Vec<String>,
580    #[serde(default = "default_true")]
581    pub smtp: bool,
582    /// True if the service author has certified this service can be
583    /// backed up safely. The default is `false` (explicit opt-in)
584    /// because the worst failure mode is a backup that takes cleanly
585    /// but won't restore (e.g. forgot to write a pg_dump hook), so
586    /// authors must consciously declare support.
587    ///
588    /// When `true`, an accompanying `[backup]` section MAY provide
589    /// hooks and excludes; when absent, the default behaviour is to
590    /// back up every top-level child of the service home dir that the
591    /// classifier marks as data.
592    #[serde(default)]
593    pub backup: bool,
594}
595
596impl Default for IntegrationFlags {
597    fn default() -> Self {
598        Self {
599            auth: vec![],
600            token_auth_method: TokenAuthMethod::default(),
601            oidc_callbacks: vec![],
602            smtp: true,
603            backup: false,
604        }
605    }
606}
607
608fn default_true() -> bool {
609    true
610}
611
612/// Per-service backup configuration. Present only when the service's
613/// `[integrations]` section sets `backup = true` AND the service needs
614/// non-default behaviour (excludes or hooks).
615///
616/// Hooks are filenames inside `configs/scripts/` (same convention as
617/// the existing `ExecStartPost=` scripts). They run with the same env
618/// as those scripts: `$SERVICE_HOME` plus everything in the service's
619/// `.env` file.
620///
621/// By default a backup is a **cold snapshot**: ryra stops the service's
622/// units, makes the data readable, runs `restic`, then restarts. The
623/// stop/chown/wipe/start lifecycle is derived from the service's units and
624/// `paths`, so a typical service.toml `[backup]` is a single `paths = [...]`
625/// line with no scripts at all. Hooks remain for the parts ryra can't
626/// derive:
627///
628/// ```text
629/// cold backup:   stop units -> chown -> [pre_backup] -> restic -> [post_backup | start]
630/// cold restore:  stop units -> wipe  -> [pre_restore] -> restic -> [post_restore | start]
631/// ```
632///
633/// `[post_backup]`/`[post_restore]` override ryra's generic restart when a
634/// service needs special bring-up (extra units, or a DB-readiness wait before
635/// the app starts). `[pre_*]` add extra prep after ryra's stop (a non-default
636/// dump, or wiping a derived dir like a search index).
637///
638/// Set `online = true` to opt out of the cold lifecycle entirely: ryra runs
639/// only the hooks around restic and never stops, chowns, or wipes. Use it for
640/// services that snapshot consistently while live (a `pre_backup` DB dump) or
641/// whose data is safe to copy in place (append-only / flat files).
642///
643/// Hooks must dump to `$SERVICE_HOME/.backup/` (a sibling of `data/`)
644/// so it's clear which files are user-owned data versus snapshot
645/// artefacts. Listing `.backup/<file>` in `paths` is required if the
646/// hook writes one; nothing is implicitly included.
647#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
648pub struct BackupConfig {
649    /// Explicit list of paths (relative to service home) to include in
650    /// the snapshot. When empty, the default is "every top-level child
651    /// of the service home dir that the classifier marks as data."
652    #[serde(default)]
653    pub paths: Vec<String>,
654    /// Restic-style exclude patterns relative to service home.
655    /// Useful for skipping caches, previews, transcoding artefacts.
656    #[serde(default)]
657    pub exclude: Vec<String>,
658    /// Back up without stopping the service. Default false: ryra takes a cold
659    /// snapshot (stop units -> chown -> restic -> restart). Set true when the
660    /// service makes itself consistent while live (a `pre_backup` dump) or its
661    /// data is safe to copy in place; then ryra runs only the hooks around
662    /// restic and never touches the service's lifecycle.
663    #[serde(default)]
664    pub online: bool,
665    /// Script filename (in `configs/scripts/`) run before the restic
666    /// snapshot, after ryra has stopped the service (cold) or while it's live
667    /// (online). Typically dumps a database to `$SERVICE_HOME/.backup/`.
668    #[serde(default)]
669    pub pre_backup: Option<String>,
670    /// Script filename run after the restic snapshot. When present on a cold
671    /// service it owns the restart (e.g. starting extra units); otherwise ryra
672    /// starts the primary unit. Also used to clean up a dump.
673    #[serde(default)]
674    pub post_backup: Option<String>,
675    /// Script filename run before `restic restore`, after ryra has stopped the
676    /// service and wiped `paths`. Typically wipes an extra derived dir (a
677    /// search index) the standard wipe doesn't cover.
678    #[serde(default)]
679    pub pre_restore: Option<String>,
680    /// Script filename run after `restic restore`. When present it owns the
681    /// restart (e.g. bring the database up, wait for it to accept connections,
682    /// then start the app); otherwise ryra starts the primary unit. Also used
683    /// to import a dump back into the live database.
684    #[serde(default)]
685    pub post_restore: Option<String>,
686}
687
688// ---------------------------------------------------------------------------
689// Validation
690// ---------------------------------------------------------------------------
691
692impl ServiceDef {
693    /// Check if this service supports the current system architecture.
694    /// Returns None if supported (or no restriction), Some(error) if not.
695    pub fn check_architecture(&self) -> Option<String> {
696        if self.service.architecture.is_empty() {
697            return None;
698        }
699        let current = current_architecture();
700        if self.service.architecture.contains(&current) {
701            None
702        } else {
703            let supported: Vec<_> = self
704                .service
705                .architecture
706                .iter()
707                .map(|a| a.to_string())
708                .collect();
709            Some(format!(
710                "{} only supports {} — this system is {current}",
711                self.service.name,
712                supported.join(", "),
713            ))
714        }
715    }
716
717    /// Returns env var names that are required — must be provided during install.
718    pub fn required_env_vars(&self) -> Vec<&str> {
719        self.env
720            .iter()
721            .filter(|e| e.kind == EnvKind::Required)
722            .map(|e| e.name.as_str())
723            .collect()
724    }
725
726    /// Validate structural invariants that serde can't enforce.
727    /// Called once after deserialization — if this returns Ok, the definition
728    /// is safe to use without further checks.
729    pub fn validate(&self) -> Result<(), String> {
730        let name = &self.service.name;
731        let mut errors: Vec<String> = Vec::new();
732
733        // --- Duplicate names ---
734
735        let mut seen_ports = std::collections::HashSet::new();
736        let mut seen_ts_https = std::collections::HashSet::new();
737        for p in &self.ports {
738            if !seen_ports.insert(&p.name) {
739                errors.push(format!("duplicate port name '{}'", p.name));
740            }
741            // `container_port = 0` is the "fill in later" placeholder `ryra init`
742            // writes for a blank port. Refuse to install until it's a real port.
743            if p.container_port == 0 {
744                errors.push(format!(
745                    "port '{}' has container_port = 0 — fill in the port your service listens on",
746                    p.name
747                ));
748            }
749            // Two ports can't be served on the same Tailscale HTTPS port —
750            // the second `tailscale serve --https=<p>` would clobber the first.
751            if let Some(https) = p.tailscale_https
752                && !seen_ts_https.insert(https)
753            {
754                errors.push(format!(
755                    "two ports map to the same tailscale_https port {https}"
756                ));
757            }
758        }
759        // If any port opts into Tailscale exposure, exactly one must own 443 —
760        // that's the web root answering at the bare `<svc>.<tailnet>.ts.net`.
761        let ts_ports: Vec<&PortDef> = self
762            .ports
763            .iter()
764            .filter(|p| p.tailscale_https.is_some())
765            .collect();
766        if !ts_ports.is_empty()
767            && ts_ports
768                .iter()
769                .filter(|p| p.tailscale_https == Some(443))
770                .count()
771                != 1
772        {
773            errors.push(
774                "services exposing ports over Tailscale must mark exactly one port \
775                 tailscale_https = 443 (the web root)"
776                    .to_string(),
777            );
778        }
779
780        // [metrics] must reference a declared port — the scrape target is
781        // built from that entry's container_port.
782        if let Some(metrics) = &self.metrics
783            && !self.ports.iter().any(|p| p.name == metrics.port)
784        {
785            errors.push(format!(
786                "[metrics] references port '{}' but no [[ports]] entry has that name",
787                metrics.port
788            ));
789        }
790
791        // Every env var name (top-level + every group member) must be unique
792        // across the whole service — podman's .env is a flat keyspace so two
793        // FOO= lines would be ambiguous.
794        let mut seen_envs: std::collections::HashSet<&str> = std::collections::HashSet::new();
795        for e in &self.env {
796            if !seen_envs.insert(&e.name) {
797                errors.push(format!("duplicate env var name '{}'", e.name));
798            }
799        }
800        for g in &self.env_groups {
801            for e in &g.env {
802                if !seen_envs.insert(&e.name) {
803                    errors.push(format!(
804                        "env var '{}' in group '{}' collides with another env var",
805                        e.name, g.name
806                    ));
807                }
808            }
809        }
810        // Choice options: at most one option per choice is ever active and
811        // sibling options are mutually exclusive, so two options of the *same*
812        // choice may reuse a name (e.g. every billing option sets
813        // BILLING_MODE). But a name shared with a top-level env, a group, or a
814        // *different* choice can be active simultaneously, so those still
815        // collide. So we check each option against `seen_envs` (top-level +
816        // groups + earlier choices) but merge only the choice's deduped union
817        // back in, never sibling-by-sibling.
818        for c in &self.choices {
819            let mut choice_envs: std::collections::HashSet<&str> = std::collections::HashSet::new();
820            for o in &c.options {
821                let mut option_envs: std::collections::HashSet<&str> =
822                    std::collections::HashSet::new();
823                for e in &o.env {
824                    if !option_envs.insert(e.name.as_str()) {
825                        errors.push(format!(
826                            "env var '{}' is declared twice in choice '{}' option '{}'",
827                            e.name, c.name, o.name
828                        ));
829                    }
830                    if seen_envs.contains(e.name.as_str()) {
831                        errors.push(format!(
832                            "env var '{}' in choice '{}' option '{}' collides with another env var",
833                            e.name, c.name, o.name
834                        ));
835                    }
836                    choice_envs.insert(e.name.as_str());
837                }
838            }
839            seen_envs.extend(choice_envs);
840        }
841
842        // --- Env var name format + kind consistency ---
843
844        for e in &self.env {
845            check_env_var(e, EnvLoc::TopLevel, &mut errors);
846        }
847
848        // --- Env group names + members ---
849
850        let mut seen_groups = std::collections::HashSet::new();
851        for g in &self.env_groups {
852            if !seen_groups.insert(&g.name) {
853                errors.push(format!("duplicate env_group name '{}'", g.name));
854            }
855            if g.name.is_empty() {
856                errors.push("env_group has empty name".to_string());
857            } else if !g
858                .name
859                .chars()
860                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
861            {
862                errors.push(format!(
863                    "env_group '{}' must be lowercase snake_case ([a-z0-9_])",
864                    g.name
865                ));
866            }
867            if g.prompt.is_empty() {
868                errors.push(format!("env_group '{}' has empty prompt", g.name));
869            }
870            if g.env.is_empty() {
871                errors.push(format!("env_group '{}' has no env vars", g.name));
872            }
873            for e in &g.env {
874                check_env_var(e, EnvLoc::Group(&g.name), &mut errors);
875            }
876        }
877
878        // --- Choice names + options ---
879        //
880        // The "exactly one selected" guarantee comes from the metadata shape (a
881        // single value per choice), so here we only police the static
882        // structure: a snake_case name distinct from groups, a prompt, two or
883        // more uniquely-named options, and a default that names one of them.
884        let mut seen_choices: std::collections::HashSet<&str> = std::collections::HashSet::new();
885        for c in &self.choices {
886            if !seen_choices.insert(c.name.as_str()) {
887                errors.push(format!("duplicate choice name '{}'", c.name));
888            }
889            if self.env_groups.iter().any(|g| g.name == c.name) {
890                errors.push(format!(
891                    "choice '{}' shares a name with an env_group; names must be distinct",
892                    c.name
893                ));
894            }
895            if c.name.is_empty() {
896                errors.push("choice has empty name".to_string());
897            } else if !c
898                .name
899                .chars()
900                .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
901            {
902                errors.push(format!(
903                    "choice '{}' must be lowercase snake_case ([a-z0-9_])",
904                    c.name
905                ));
906            }
907            if c.prompt.is_empty() {
908                errors.push(format!("choice '{}' has empty prompt", c.name));
909            }
910            // Fewer than two options is not a choice.
911            if c.options.len() < 2 {
912                errors.push(format!(
913                    "choice '{}' has {} option(s); a choice needs at least two",
914                    c.name,
915                    c.options.len()
916                ));
917            }
918            let mut seen_options: std::collections::HashSet<&str> =
919                std::collections::HashSet::new();
920            for o in &c.options {
921                if !seen_options.insert(o.name.as_str()) {
922                    errors.push(format!(
923                        "duplicate option '{}' in choice '{}'",
924                        o.name, c.name
925                    ));
926                }
927                if o.name.is_empty() {
928                    errors.push(format!("choice '{}' has an option with empty name", c.name));
929                } else if !o
930                    .name
931                    .chars()
932                    .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
933                {
934                    errors.push(format!(
935                        "option '{}' in choice '{}' must be lowercase snake_case ([a-z0-9_])",
936                        o.name, c.name
937                    ));
938                }
939                for e in &o.env {
940                    check_env_var(
941                        e,
942                        EnvLoc::ChoiceOption {
943                            choice: &c.name,
944                            option: &o.name,
945                        },
946                        &mut errors,
947                    );
948                }
949            }
950            // The default must name a real option, else a non-interactive add
951            // would resolve to nothing.
952            if !c.options.iter().any(|o| o.name == c.default) {
953                errors.push(format!(
954                    "choice '{}' default '{}' names no option (have: {})",
955                    c.name,
956                    c.default,
957                    c.options
958                        .iter()
959                        .map(|o| o.name.as_str())
960                        .collect::<Vec<_>>()
961                        .join(", ")
962                ));
963            }
964        }
965
966        // --- RAM requirements consistency ---
967
968        if let Some(ref req) = self.requirements
969            && let Some(rec) = req.ram.recommended
970            && rec < req.ram.min
971        {
972            errors.push(format!(
973                "recommended RAM ({rec}MB) is less than minimum ({}MB)",
974                req.ram.min
975            ));
976        }
977
978        // --- Backup consistency ---
979        //
980        // The `[backup]` section is only meaningful when the author has
981        // certified the service is backup-safe via `backup = true`. If
982        // they wrote hooks/excludes without flipping the flag we'd
983        // silently ship a service whose backup support is half-declared,
984        // so reject it loudly.
985        if let Some(ref backup) = self.backup
986            && !self.integrations.backup
987        {
988            errors.push("[backup] section requires `backup = true` in [integrations]".to_string());
989            // No-op read so the binding isn't unused if all sub-checks
990            // below get gated out by serde defaults.
991            let _ = backup;
992        }
993        if let Some(ref backup) = self.backup {
994            for (label, hook) in [
995                ("pre_backup", &backup.pre_backup),
996                ("post_backup", &backup.post_backup),
997                ("pre_restore", &backup.pre_restore),
998                ("post_restore", &backup.post_restore),
999            ] {
1000                if let Some(script) = hook
1001                    && (script.is_empty() || script.contains('/') || script.contains(".."))
1002                {
1003                    errors.push(format!(
1004                        "backup hook '{label}' must be a bare filename under configs/scripts/ \
1005                         (got {script:?})"
1006                    ));
1007                }
1008            }
1009            for p in &backup.paths {
1010                if p.is_empty() || p.starts_with('/') || p.contains("..") {
1011                    errors.push(format!(
1012                        "backup path {p:?} must be a relative path within the service home"
1013                    ));
1014                }
1015            }
1016        }
1017
1018        // --- Runtime / build consistency ---
1019        // Make "native without a build target" and "podman with a build
1020        // section" unrepresentable past load: a native service needs to know
1021        // which binary to run; a podman service has no business declaring one.
1022        match self.service.runtime {
1023            Runtime::Native => match &self.service.run {
1024                None => errors.push(
1025                    "runtime = \"native\" requires a `run` command under [service]".to_string(),
1026                ),
1027                Some(run) if run.trim().is_empty() => {
1028                    errors.push("[service].run must not be empty".to_string())
1029                }
1030                Some(_) => {}
1031            },
1032            Runtime::Podman => {
1033                if self.service.run.is_some() || self.service.build.is_some() {
1034                    errors.push(
1035                        "`run` / `build` are only valid for runtime = \"native\" services"
1036                            .to_string(),
1037                    );
1038                }
1039            }
1040        }
1041
1042        // --- Blue/green consistency ---
1043        // A blue/green deploy swaps a Caddy upstream between two instances, so
1044        // it needs (a) a port to route, and (b) a readiness probe to know the
1045        // standby is live before cutting over. Make a half-configured strategy
1046        // unrepresentable rather than letting it surface as a runtime surprise
1047        // mid-deploy. The Caddy-backed-exposure requirement is enforced at
1048        // install time (exposure is chosen by `ryra add`, not service.toml).
1049        if self.service.deploy == DeployStrategy::BlueGreen {
1050            if self.ports.is_empty() {
1051                errors.push(
1052                    "deploy = \"blue-green\" requires at least one [[ports]] entry to route"
1053                        .to_string(),
1054                );
1055            }
1056            match self.service.health_check.as_deref() {
1057                None => errors.push(
1058                    "deploy = \"blue-green\" requires a `health_check` path under [service]"
1059                        .to_string(),
1060                ),
1061                Some(p) if !p.starts_with('/') => errors.push(format!(
1062                    "`health_check` must be an absolute path starting with '/', got {p:?}"
1063                )),
1064                Some(_) => {}
1065            }
1066            if self.service.health_timeout == Some(0) {
1067                errors.push(
1068                    "`health_timeout` must be greater than 0 seconds (omit it for the default)"
1069                        .to_string(),
1070                );
1071            }
1072        }
1073
1074        if errors.is_empty() {
1075            Ok(())
1076        } else {
1077            Err(format!("{name}: {}", errors.join("; ")))
1078        }
1079    }
1080}
1081
1082/// Where an [`EnvVar`] is declared, used to locate it in validation errors. A
1083/// closed sum type rather than a free-form string, so a caller can't pass an
1084/// arbitrary label and [`check_env_var`] `match`es it to build the suffix.
1085enum EnvLoc<'a> {
1086    TopLevel,
1087    Group(&'a str),
1088    ChoiceOption { choice: &'a str, option: &'a str },
1089}
1090
1091impl std::fmt::Display for EnvLoc<'_> {
1092    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1093        match self {
1094            EnvLoc::TopLevel => Ok(()),
1095            EnvLoc::Group(g) => write!(f, " in group '{g}'"),
1096            EnvLoc::ChoiceOption { choice, option } => {
1097                write!(f, " in choice '{choice}' option '{option}'")
1098            }
1099        }
1100    }
1101}
1102
1103/// Name-format + kind-consistency check for a single `EnvVar`, shared by
1104/// top-level `[[env]]`, `[[env_group.env]]`, and `[[choice.option.env]]`. `loc`
1105/// is woven into each error so the offending declaration is locatable.
1106fn check_env_var(e: &EnvVar, loc: EnvLoc, errors: &mut Vec<String>) {
1107    if e.name.is_empty() {
1108        errors.push(format!("env var has empty name{loc}"));
1109    } else if !e
1110        .name
1111        .chars()
1112        .next()
1113        .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
1114    {
1115        errors.push(format!(
1116            "env var '{}'{loc} must start with a letter or _",
1117            e.name
1118        ));
1119    } else if !e
1120        .name
1121        .chars()
1122        .all(|c| c.is_ascii_alphanumeric() || c == '_')
1123    {
1124        errors.push(format!(
1125            "env var '{}'{loc} contains invalid characters (must match [A-Za-z0-9_])",
1126            e.name
1127        ));
1128    }
1129    if e.kind == EnvKind::Required && e.value.contains("{{secret.") {
1130        errors.push(format!(
1131            "env var '{}'{loc} is kind=required but has a secret template default; use kind=prompted or kind=default",
1132            e.name
1133        ));
1134    }
1135}
1136
1137/// Detect the current system architecture using OCI/Docker naming conventions.
1138pub fn current_architecture() -> Arch {
1139    match std::env::consts::ARCH {
1140        "x86_64" => Arch::Amd64,
1141        "aarch64" => Arch::Arm64,
1142        // Fallback: default to amd64 for unknown architectures.
1143        // The service's check_architecture() will catch unsupported ones.
1144        _ => Arch::Amd64,
1145    }
1146}
1147
1148#[cfg(test)]
1149mod backup_tests {
1150    use super::*;
1151
1152    fn parse(toml_src: &str) -> ServiceDef {
1153        toml::from_str(toml_src).expect("parse")
1154    }
1155
1156    #[test]
1157    fn blue_green_requires_health_check() {
1158        let svc = parse(
1159            r#"
1160[service]
1161name = "x"
1162description = "x"
1163deploy = "blue-green"
1164
1165[[ports]]
1166name = "http"
1167container_port = 8080
1168"#,
1169        );
1170        let err = svc.validate().expect_err("must reject");
1171        assert!(err.contains("health_check"), "got: {err}");
1172    }
1173
1174    #[test]
1175    fn blue_green_health_check_must_be_absolute_path() {
1176        let svc = parse(
1177            r#"
1178[service]
1179name = "x"
1180description = "x"
1181deploy = "blue-green"
1182health_check = "healthz"
1183
1184[[ports]]
1185name = "http"
1186container_port = 8080
1187"#,
1188        );
1189        let err = svc.validate().expect_err("must reject");
1190        assert!(err.contains("absolute path"), "got: {err}");
1191    }
1192
1193    #[test]
1194    fn blue_green_requires_a_port() {
1195        let svc = parse(
1196            r#"
1197[service]
1198name = "x"
1199description = "x"
1200deploy = "blue-green"
1201health_check = "/healthz"
1202"#,
1203        );
1204        let err = svc.validate().expect_err("must reject");
1205        assert!(err.contains("[[ports]]"), "got: {err}");
1206    }
1207
1208    #[test]
1209    fn blue_green_with_port_and_health_check_validates() {
1210        let svc = parse(
1211            r#"
1212[service]
1213name = "x"
1214description = "x"
1215deploy = "blue-green"
1216health_check = "/healthz"
1217
1218[[ports]]
1219name = "http"
1220container_port = 8080
1221"#,
1222        );
1223        assert!(svc.validate().is_ok());
1224        assert_eq!(svc.service.deploy, DeployStrategy::BlueGreen);
1225    }
1226
1227    #[test]
1228    fn health_timeout_defaults_to_120_and_honors_override() {
1229        let default = parse(
1230            r#"
1231[service]
1232name = "x"
1233description = "x"
1234deploy = "blue-green"
1235health_check = "/healthz"
1236
1237[[ports]]
1238name = "http"
1239container_port = 8080
1240"#,
1241        );
1242        assert_eq!(default.service.health_timeout, None);
1243        assert_eq!(default.service.health_timeout_secs(), 120);
1244
1245        let custom = parse(
1246            r#"
1247[service]
1248name = "x"
1249description = "x"
1250deploy = "blue-green"
1251health_check = "/healthz"
1252health_timeout = 300
1253
1254[[ports]]
1255name = "http"
1256container_port = 8080
1257"#,
1258        );
1259        assert_eq!(custom.service.health_timeout_secs(), 300);
1260        assert!(custom.validate().is_ok());
1261    }
1262
1263    #[test]
1264    fn health_timeout_zero_is_rejected() {
1265        let svc = parse(
1266            r#"
1267[service]
1268name = "x"
1269description = "x"
1270deploy = "blue-green"
1271health_check = "/healthz"
1272health_timeout = 0
1273
1274[[ports]]
1275name = "http"
1276container_port = 8080
1277"#,
1278        );
1279        let err = svc.validate().expect_err("must reject");
1280        assert!(err.contains("health_timeout"), "got: {err}");
1281    }
1282
1283    #[test]
1284    fn deploy_defaults_to_restart_and_is_omitted_when_serialized() {
1285        // No `deploy` line -> Restart, and a Restart strategy must not write a
1286        // redundant `deploy = "restart"` back out (skip_serializing_if).
1287        let svc = parse(
1288            r#"
1289[service]
1290name = "x"
1291description = "x"
1292
1293[[ports]]
1294name = "http"
1295container_port = 8080
1296"#,
1297        );
1298        assert_eq!(svc.service.deploy, DeployStrategy::Restart);
1299        let text = toml::to_string(&svc.service).expect("serialize ServiceMeta");
1300        assert!(!text.contains("deploy"), "got: {text}");
1301    }
1302
1303    #[test]
1304    fn tailscale_https_requires_exactly_one_root() {
1305        // Two tailscale-exposed ports but neither owns 443 → rejected.
1306        let svc = parse(
1307            r#"
1308[service]
1309name = "x"
1310description = "x"
1311
1312[[ports]]
1313name = "http"
1314container_port = 8080
1315tailscale_https = 8080
1316
1317[[ports]]
1318name = "photos"
1319container_port = 3000
1320tailscale_https = 3000
1321"#,
1322        );
1323        let err = svc.validate().expect_err("must reject");
1324        assert!(err.contains("tailscale_https = 443"), "got: {err}");
1325    }
1326
1327    #[test]
1328    fn tailscale_https_duplicate_port_rejected() {
1329        let svc = parse(
1330            r#"
1331[service]
1332name = "x"
1333description = "x"
1334
1335[[ports]]
1336name = "a"
1337container_port = 1
1338tailscale_https = 443
1339
1340[[ports]]
1341name = "b"
1342container_port = 2
1343tailscale_https = 443
1344"#,
1345        );
1346        let err = svc.validate().expect_err("must reject");
1347        assert!(err.contains("same tailscale_https"), "got: {err}");
1348    }
1349
1350    #[test]
1351    fn tailscale_https_one_root_plus_api_validates() {
1352        let svc = parse(
1353            r#"
1354[service]
1355name = "x"
1356description = "x"
1357
1358[[ports]]
1359name = "http"
1360container_port = 8080
1361tailscale_https = 8080
1362
1363[[ports]]
1364name = "photos"
1365container_port = 3000
1366tailscale_https = 443
1367"#,
1368        );
1369        svc.validate()
1370            .expect("one 443 root + one api port is valid");
1371    }
1372
1373    #[test]
1374    fn backup_defaults_to_false_when_omitted() {
1375        let svc = parse(
1376            r#"
1377[service]
1378name = "x"
1379description = "x"
1380"#,
1381        );
1382        assert!(!svc.integrations.backup);
1383        assert!(svc.backup.is_none());
1384        svc.validate().expect("default is valid");
1385    }
1386
1387    #[test]
1388    fn backup_section_alone_is_rejected_without_integration_flag() {
1389        let svc = parse(
1390            r#"
1391[service]
1392name = "x"
1393description = "x"
1394
1395[backup]
1396"#,
1397        );
1398        let err = svc.validate().expect_err("must reject");
1399        assert!(
1400            err.contains("backup = true"),
1401            "error mentions the required flag: {err}"
1402        );
1403    }
1404
1405    #[test]
1406    fn backup_supported_without_hooks_validates() {
1407        let svc = parse(
1408            r#"
1409[service]
1410name = "x"
1411description = "x"
1412
1413[integrations]
1414backup = true
1415"#,
1416        );
1417        assert!(svc.integrations.backup);
1418        assert!(svc.backup.is_none());
1419        svc.validate().expect("ok without [backup] table");
1420    }
1421
1422    #[test]
1423    fn backup_with_full_hooks_validates() {
1424        let svc = parse(
1425            r#"
1426[service]
1427name = "x"
1428description = "x"
1429
1430[integrations]
1431backup = true
1432
1433[backup]
1434paths = [".backup/db.sql.gz", "data"]
1435exclude = ["data/cache"]
1436pre_backup = "backup-pre.sh"
1437post_backup = "backup-post.sh"
1438pre_restore = "restore-pre.sh"
1439post_restore = "restore-post.sh"
1440"#,
1441        );
1442        svc.validate().expect("ok");
1443        let backup = svc.backup.as_ref().expect("section present");
1444        assert_eq!(backup.paths, vec![".backup/db.sql.gz", "data"]);
1445        assert_eq!(backup.pre_backup.as_deref(), Some("backup-pre.sh"));
1446    }
1447
1448    #[test]
1449    fn backup_hook_with_slash_is_rejected() {
1450        let svc = parse(
1451            r#"
1452[service]
1453name = "x"
1454description = "x"
1455
1456[integrations]
1457backup = true
1458
1459[backup]
1460pre_backup = "subdir/script.sh"
1461"#,
1462        );
1463        let err = svc.validate().expect_err("must reject");
1464        assert!(err.contains("pre_backup"), "{err}");
1465    }
1466
1467    #[test]
1468    fn backup_hook_with_dotdot_is_rejected() {
1469        let svc = parse(
1470            r#"
1471[service]
1472name = "x"
1473description = "x"
1474
1475[integrations]
1476backup = true
1477
1478[backup]
1479post_backup = "../escape.sh"
1480"#,
1481        );
1482        let err = svc.validate().expect_err("must reject");
1483        assert!(err.contains("post_backup"), "{err}");
1484    }
1485
1486    #[test]
1487    fn backup_absolute_path_is_rejected() {
1488        let svc = parse(
1489            r#"
1490[service]
1491name = "x"
1492description = "x"
1493
1494[integrations]
1495backup = true
1496
1497[backup]
1498paths = ["/etc/passwd"]
1499"#,
1500        );
1501        let err = svc.validate().expect_err("must reject");
1502        assert!(err.contains("/etc/passwd"), "{err}");
1503    }
1504
1505    #[test]
1506    fn backup_path_with_dotdot_is_rejected() {
1507        let svc = parse(
1508            r#"
1509[service]
1510name = "x"
1511description = "x"
1512
1513[integrations]
1514backup = true
1515
1516[backup]
1517paths = ["../../somewhere"]
1518"#,
1519        );
1520        let err = svc.validate().expect_err("must reject");
1521        assert!(err.contains("somewhere"), "{err}");
1522    }
1523}
1524
1525#[cfg(test)]
1526mod https_requirement_tests {
1527    use super::*;
1528
1529    fn parse(toml_src: &str) -> ServiceDef {
1530        toml::from_str(toml_src).expect("parse")
1531    }
1532
1533    /// Every shipped registry `service.toml` must parse and validate under the
1534    /// current schema. Guards against a core change (a new field, a stricter
1535    /// rule) silently breaking a catalog service. Skips gracefully if the
1536    /// registry dir isn't present (e.g. a packaged build of just the crate).
1537    #[test]
1538    fn all_registry_services_parse_and_validate() {
1539        let registry = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../registry");
1540        if !registry.is_dir() {
1541            eprintln!("registry dir not found ({}); skipping", registry.display());
1542            return;
1543        }
1544        let mut failures = Vec::new();
1545        let entries = std::fs::read_dir(&registry).expect("read registry dir");
1546        for entry in entries {
1547            let entry = entry.expect("dir entry");
1548            let svc_toml = entry.path().join("service.toml");
1549            if !svc_toml.is_file() {
1550                continue;
1551            }
1552            let name = entry.file_name().to_string_lossy().into_owned();
1553            let text = std::fs::read_to_string(&svc_toml).expect("read service.toml");
1554            match toml::from_str::<ServiceDef>(&text) {
1555                Ok(def) => {
1556                    if let Err(e) = def.validate() {
1557                        failures.push(format!("{name}: validate: {e}"));
1558                    }
1559                }
1560                Err(e) => failures.push(format!("{name}: parse: {e}")),
1561            }
1562        }
1563        assert!(
1564            failures.is_empty(),
1565            "registry service.toml failures:\n  {}",
1566            failures.join("\n  ")
1567        );
1568    }
1569
1570    #[test]
1571    fn never_service_stays_http() {
1572        assert!(!HttpsRequirement::Never.needs_https(false, None));
1573        // Even with --auth, a service that didn't opt into HTTPS stays HTTP.
1574        // This is the RFC 8252 loopback case: http://127.0.0.1 is a valid
1575        // OIDC redirect_uri and most services (forgejo, etc.) work fine
1576        // that way.
1577        assert!(!HttpsRequirement::Never.needs_https(true, None));
1578        // Explicit http:// URL also stays HTTP.
1579        assert!(!HttpsRequirement::Never.needs_https(true, Some("http://foo.example.com")));
1580    }
1581
1582    #[test]
1583    fn always_service_always_promotes() {
1584        assert!(HttpsRequirement::Always.needs_https(false, None));
1585        assert!(HttpsRequirement::Always.needs_https(false, Some("http://foo.example.com")));
1586    }
1587
1588    #[test]
1589    fn auth_service_promotes_only_with_auth() {
1590        // The regression this guards: `ryra add nextcloud --auth` without
1591        // --url used to quietly install over HTTP and the SSO button never
1592        // rendered (user_oidc refuses to show it without HTTPS).
1593        assert!(HttpsRequirement::Auth.needs_https(true, None));
1594        // Without --auth, even an `https = "auth"` service stays HTTP.
1595        assert!(!HttpsRequirement::Auth.needs_https(false, None));
1596    }
1597
1598    #[test]
1599    fn explicit_https_url_promotes() {
1600        assert!(HttpsRequirement::Never.needs_https(false, Some("https://foo.example.com")));
1601    }
1602
1603    // --- [[choice]] validation ---
1604
1605    const BILLING_CHOICE: &str = r#"
1606[service]
1607name = "x"
1608description = "x"
1609
1610[[choice]]
1611name = "billing"
1612prompt = "Billing mode"
1613default = "mock"
1614
1615[[choice.option]]
1616name = "live"
1617label = "Stripe"
1618[[choice.option.env]]
1619name = "BILLING_MODE"
1620value = "live"
1621[[choice.option.env]]
1622name = "STRIPE_SECRET_KEY"
1623value = ""
1624kind = "required"
1625
1626[[choice.option]]
1627name = "mock"
1628[[choice.option.env]]
1629name = "BILLING_MODE"
1630value = "mock"
1631"#;
1632
1633    #[test]
1634    fn valid_choice_validates() {
1635        parse(BILLING_CHOICE)
1636            .validate()
1637            .expect("a well-formed choice is valid");
1638    }
1639
1640    #[test]
1641    fn choice_option_carries_quadlets() {
1642        let def = parse(
1643            r#"
1644[service]
1645name = "x"
1646description = "x"
1647
1648[[choice]]
1649name = "database"
1650prompt = "Database"
1651default = "internal"
1652
1653[[choice.option]]
1654name = "internal"
1655quadlets = ["x-postgres.container"]
1656[[choice.option.env]]
1657name = "DATABASE_URL"
1658value = "postgres://ryra@postgres/x"
1659
1660[[choice.option]]
1661name = "external"
1662[[choice.option.env]]
1663name = "DATABASE_URL"
1664value = ""
1665kind = "required"
1666"#,
1667        );
1668        def.validate().expect("valid");
1669        let internal = &def.choices[0].options[0];
1670        assert_eq!(internal.quadlets, vec!["x-postgres.container".to_string()]);
1671        assert!(def.choices[0].options[1].quadlets.is_empty());
1672    }
1673
1674    #[test]
1675    fn sibling_options_may_reuse_an_env_name() {
1676        // Both `live` and `mock` set BILLING_MODE — allowed, since at most one
1677        // option is ever active.
1678        let def = parse(BILLING_CHOICE);
1679        let billing = &def.choices[0];
1680        assert!(
1681            billing
1682                .options
1683                .iter()
1684                .all(|o| o.env.iter().any(|e| e.name == "BILLING_MODE"))
1685        );
1686        def.validate().expect("sibling reuse is allowed");
1687    }
1688
1689    #[test]
1690    fn choice_needs_at_least_two_options() {
1691        let svc = parse(
1692            r#"
1693[service]
1694name = "x"
1695description = "x"
1696
1697[[choice]]
1698name = "billing"
1699prompt = "p"
1700default = "only"
1701
1702[[choice.option]]
1703name = "only"
1704"#,
1705        );
1706        let err = svc.validate().expect_err("one option is not a choice");
1707        assert!(err.contains("at least two"), "got: {err}");
1708    }
1709
1710    #[test]
1711    fn choice_default_must_name_an_option() {
1712        let svc = parse(
1713            r#"
1714[service]
1715name = "x"
1716description = "x"
1717
1718[[choice]]
1719name = "billing"
1720prompt = "p"
1721default = "nope"
1722
1723[[choice.option]]
1724name = "live"
1725[[choice.option]]
1726name = "mock"
1727"#,
1728        );
1729        let err = svc.validate().expect_err("bad default rejected");
1730        assert!(err.contains("names no option"), "got: {err}");
1731    }
1732
1733    #[test]
1734    fn duplicate_option_name_rejected() {
1735        let svc = parse(
1736            r#"
1737[service]
1738name = "x"
1739description = "x"
1740
1741[[choice]]
1742name = "billing"
1743prompt = "p"
1744default = "live"
1745
1746[[choice.option]]
1747name = "live"
1748[[choice.option]]
1749name = "live"
1750"#,
1751        );
1752        let err = svc.validate().expect_err("dup option rejected");
1753        assert!(err.contains("duplicate option"), "got: {err}");
1754    }
1755
1756    #[test]
1757    fn two_choices_sharing_an_env_name_collide() {
1758        // Different choices can both be active, so a shared name is a real
1759        // collision (unlike sibling options of one choice).
1760        let svc = parse(
1761            r#"
1762[service]
1763name = "x"
1764description = "x"
1765
1766[[choice]]
1767name = "a"
1768prompt = "p"
1769default = "one"
1770[[choice.option]]
1771name = "one"
1772[[choice.option.env]]
1773name = "SHARED"
1774value = "1"
1775[[choice.option]]
1776name = "two"
1777
1778[[choice]]
1779name = "b"
1780prompt = "p"
1781default = "one"
1782[[choice.option]]
1783name = "one"
1784[[choice.option.env]]
1785name = "SHARED"
1786value = "2"
1787[[choice.option]]
1788name = "two"
1789"#,
1790        );
1791        let err = svc.validate().expect_err("cross-choice collision rejected");
1792        assert!(err.contains("collides"), "got: {err}");
1793    }
1794
1795    #[test]
1796    fn choice_name_colliding_with_group_rejected() {
1797        let svc = parse(
1798            r#"
1799[service]
1800name = "x"
1801description = "x"
1802
1803[[env_group]]
1804name = "billing"
1805prompt = "p"
1806[[env_group.env]]
1807name = "FOO"
1808value = "1"
1809
1810[[choice]]
1811name = "billing"
1812prompt = "p"
1813default = "live"
1814[[choice.option]]
1815name = "live"
1816[[choice.option]]
1817name = "mock"
1818"#,
1819        );
1820        let err = svc.validate().expect_err("name clash rejected");
1821        assert!(err.contains("shares a name"), "got: {err}");
1822    }
1823}