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/// Pre/post hooks form a pair around the operation:
622///
623/// ```text
624/// backup:  [pre_backup]  -> restic snapshot   -> [post_backup]
625/// restore: [pre_restore] -> restic restore    -> [post_restore]
626/// ```
627///
628/// Hooks must dump to `$SERVICE_HOME/.backup/` (a sibling of `data/`)
629/// so it's clear which files are user-owned data versus snapshot
630/// artefacts. Listing `.backup/<file>` in `paths` is required if the
631/// hook writes one; nothing is implicitly included.
632#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
633pub struct BackupConfig {
634    /// Explicit list of paths (relative to service home) to include in
635    /// the snapshot. When empty, the default is "every top-level child
636    /// of the service home dir that the classifier marks as data."
637    #[serde(default)]
638    pub paths: Vec<String>,
639    /// Restic-style exclude patterns relative to service home.
640    /// Useful for skipping caches, previews, transcoding artefacts.
641    #[serde(default)]
642    pub exclude: Vec<String>,
643    /// Script filename (in `configs/scripts/`) run before the restic
644    /// snapshot. Typically dumps a database to `$SERVICE_HOME/.backup/`.
645    #[serde(default)]
646    pub pre_backup: Option<String>,
647    /// Script filename run after a successful restic snapshot.
648    /// Typically cleans up `$SERVICE_HOME/.backup/`.
649    #[serde(default)]
650    pub post_backup: Option<String>,
651    /// Script filename run before restoring (typically stops the
652    /// service and wipes the live data dir).
653    #[serde(default)]
654    pub pre_restore: Option<String>,
655    /// Script filename run after restoring (typically imports the
656    /// dump back into the live database and restarts the service).
657    #[serde(default)]
658    pub post_restore: Option<String>,
659}
660
661// ---------------------------------------------------------------------------
662// Validation
663// ---------------------------------------------------------------------------
664
665impl ServiceDef {
666    /// Check if this service supports the current system architecture.
667    /// Returns None if supported (or no restriction), Some(error) if not.
668    pub fn check_architecture(&self) -> Option<String> {
669        if self.service.architecture.is_empty() {
670            return None;
671        }
672        let current = current_architecture();
673        if self.service.architecture.contains(&current) {
674            None
675        } else {
676            let supported: Vec<_> = self
677                .service
678                .architecture
679                .iter()
680                .map(|a| a.to_string())
681                .collect();
682            Some(format!(
683                "{} only supports {} — this system is {current}",
684                self.service.name,
685                supported.join(", "),
686            ))
687        }
688    }
689
690    /// Returns env var names that are required — must be provided during install.
691    pub fn required_env_vars(&self) -> Vec<&str> {
692        self.env
693            .iter()
694            .filter(|e| e.kind == EnvKind::Required)
695            .map(|e| e.name.as_str())
696            .collect()
697    }
698
699    /// Validate structural invariants that serde can't enforce.
700    /// Called once after deserialization — if this returns Ok, the definition
701    /// is safe to use without further checks.
702    pub fn validate(&self) -> Result<(), String> {
703        let name = &self.service.name;
704        let mut errors: Vec<String> = Vec::new();
705
706        // --- Duplicate names ---
707
708        let mut seen_ports = std::collections::HashSet::new();
709        let mut seen_ts_https = std::collections::HashSet::new();
710        for p in &self.ports {
711            if !seen_ports.insert(&p.name) {
712                errors.push(format!("duplicate port name '{}'", p.name));
713            }
714            // `container_port = 0` is the "fill in later" placeholder `ryra init`
715            // writes for a blank port. Refuse to install until it's a real port.
716            if p.container_port == 0 {
717                errors.push(format!(
718                    "port '{}' has container_port = 0 — fill in the port your service listens on",
719                    p.name
720                ));
721            }
722            // Two ports can't be served on the same Tailscale HTTPS port —
723            // the second `tailscale serve --https=<p>` would clobber the first.
724            if let Some(https) = p.tailscale_https
725                && !seen_ts_https.insert(https)
726            {
727                errors.push(format!(
728                    "two ports map to the same tailscale_https port {https}"
729                ));
730            }
731        }
732        // If any port opts into Tailscale exposure, exactly one must own 443 —
733        // that's the web root answering at the bare `<svc>.<tailnet>.ts.net`.
734        let ts_ports: Vec<&PortDef> = self
735            .ports
736            .iter()
737            .filter(|p| p.tailscale_https.is_some())
738            .collect();
739        if !ts_ports.is_empty()
740            && ts_ports
741                .iter()
742                .filter(|p| p.tailscale_https == Some(443))
743                .count()
744                != 1
745        {
746            errors.push(
747                "services exposing ports over Tailscale must mark exactly one port \
748                 tailscale_https = 443 (the web root)"
749                    .to_string(),
750            );
751        }
752
753        // [metrics] must reference a declared port — the scrape target is
754        // built from that entry's container_port.
755        if let Some(metrics) = &self.metrics
756            && !self.ports.iter().any(|p| p.name == metrics.port)
757        {
758            errors.push(format!(
759                "[metrics] references port '{}' but no [[ports]] entry has that name",
760                metrics.port
761            ));
762        }
763
764        // Every env var name (top-level + every group member) must be unique
765        // across the whole service — podman's .env is a flat keyspace so two
766        // FOO= lines would be ambiguous.
767        let mut seen_envs: std::collections::HashSet<&str> = std::collections::HashSet::new();
768        for e in &self.env {
769            if !seen_envs.insert(&e.name) {
770                errors.push(format!("duplicate env var name '{}'", e.name));
771            }
772        }
773        for g in &self.env_groups {
774            for e in &g.env {
775                if !seen_envs.insert(&e.name) {
776                    errors.push(format!(
777                        "env var '{}' in group '{}' collides with another env var",
778                        e.name, g.name
779                    ));
780                }
781            }
782        }
783        // Choice options: at most one option per choice is ever active and
784        // sibling options are mutually exclusive, so two options of the *same*
785        // choice may reuse a name (e.g. every billing option sets
786        // BILLING_MODE). But a name shared with a top-level env, a group, or a
787        // *different* choice can be active simultaneously, so those still
788        // collide. So we check each option against `seen_envs` (top-level +
789        // groups + earlier choices) but merge only the choice's deduped union
790        // back in, never sibling-by-sibling.
791        for c in &self.choices {
792            let mut choice_envs: std::collections::HashSet<&str> = std::collections::HashSet::new();
793            for o in &c.options {
794                let mut option_envs: std::collections::HashSet<&str> =
795                    std::collections::HashSet::new();
796                for e in &o.env {
797                    if !option_envs.insert(e.name.as_str()) {
798                        errors.push(format!(
799                            "env var '{}' is declared twice in choice '{}' option '{}'",
800                            e.name, c.name, o.name
801                        ));
802                    }
803                    if seen_envs.contains(e.name.as_str()) {
804                        errors.push(format!(
805                            "env var '{}' in choice '{}' option '{}' collides with another env var",
806                            e.name, c.name, o.name
807                        ));
808                    }
809                    choice_envs.insert(e.name.as_str());
810                }
811            }
812            seen_envs.extend(choice_envs);
813        }
814
815        // --- Env var name format + kind consistency ---
816
817        for e in &self.env {
818            check_env_var(e, EnvLoc::TopLevel, &mut errors);
819        }
820
821        // --- Env group names + members ---
822
823        let mut seen_groups = std::collections::HashSet::new();
824        for g in &self.env_groups {
825            if !seen_groups.insert(&g.name) {
826                errors.push(format!("duplicate env_group name '{}'", g.name));
827            }
828            if g.name.is_empty() {
829                errors.push("env_group has empty name".to_string());
830            } else if !g
831                .name
832                .chars()
833                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
834            {
835                errors.push(format!(
836                    "env_group '{}' must be lowercase snake_case ([a-z0-9_])",
837                    g.name
838                ));
839            }
840            if g.prompt.is_empty() {
841                errors.push(format!("env_group '{}' has empty prompt", g.name));
842            }
843            if g.env.is_empty() {
844                errors.push(format!("env_group '{}' has no env vars", g.name));
845            }
846            for e in &g.env {
847                check_env_var(e, EnvLoc::Group(&g.name), &mut errors);
848            }
849        }
850
851        // --- Choice names + options ---
852        //
853        // The "exactly one selected" guarantee comes from the metadata shape (a
854        // single value per choice), so here we only police the static
855        // structure: a snake_case name distinct from groups, a prompt, two or
856        // more uniquely-named options, and a default that names one of them.
857        let mut seen_choices: std::collections::HashSet<&str> = std::collections::HashSet::new();
858        for c in &self.choices {
859            if !seen_choices.insert(c.name.as_str()) {
860                errors.push(format!("duplicate choice name '{}'", c.name));
861            }
862            if self.env_groups.iter().any(|g| g.name == c.name) {
863                errors.push(format!(
864                    "choice '{}' shares a name with an env_group; names must be distinct",
865                    c.name
866                ));
867            }
868            if c.name.is_empty() {
869                errors.push("choice has empty name".to_string());
870            } else if !c
871                .name
872                .chars()
873                .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
874            {
875                errors.push(format!(
876                    "choice '{}' must be lowercase snake_case ([a-z0-9_])",
877                    c.name
878                ));
879            }
880            if c.prompt.is_empty() {
881                errors.push(format!("choice '{}' has empty prompt", c.name));
882            }
883            // Fewer than two options is not a choice.
884            if c.options.len() < 2 {
885                errors.push(format!(
886                    "choice '{}' has {} option(s); a choice needs at least two",
887                    c.name,
888                    c.options.len()
889                ));
890            }
891            let mut seen_options: std::collections::HashSet<&str> =
892                std::collections::HashSet::new();
893            for o in &c.options {
894                if !seen_options.insert(o.name.as_str()) {
895                    errors.push(format!(
896                        "duplicate option '{}' in choice '{}'",
897                        o.name, c.name
898                    ));
899                }
900                if o.name.is_empty() {
901                    errors.push(format!("choice '{}' has an option with empty name", c.name));
902                } else if !o
903                    .name
904                    .chars()
905                    .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
906                {
907                    errors.push(format!(
908                        "option '{}' in choice '{}' must be lowercase snake_case ([a-z0-9_])",
909                        o.name, c.name
910                    ));
911                }
912                for e in &o.env {
913                    check_env_var(
914                        e,
915                        EnvLoc::ChoiceOption {
916                            choice: &c.name,
917                            option: &o.name,
918                        },
919                        &mut errors,
920                    );
921                }
922            }
923            // The default must name a real option, else a non-interactive add
924            // would resolve to nothing.
925            if !c.options.iter().any(|o| o.name == c.default) {
926                errors.push(format!(
927                    "choice '{}' default '{}' names no option (have: {})",
928                    c.name,
929                    c.default,
930                    c.options
931                        .iter()
932                        .map(|o| o.name.as_str())
933                        .collect::<Vec<_>>()
934                        .join(", ")
935                ));
936            }
937        }
938
939        // --- RAM requirements consistency ---
940
941        if let Some(ref req) = self.requirements
942            && let Some(rec) = req.ram.recommended
943            && rec < req.ram.min
944        {
945            errors.push(format!(
946                "recommended RAM ({rec}MB) is less than minimum ({}MB)",
947                req.ram.min
948            ));
949        }
950
951        // --- Backup consistency ---
952        //
953        // The `[backup]` section is only meaningful when the author has
954        // certified the service is backup-safe via `backup = true`. If
955        // they wrote hooks/excludes without flipping the flag we'd
956        // silently ship a service whose backup support is half-declared,
957        // so reject it loudly.
958        if let Some(ref backup) = self.backup
959            && !self.integrations.backup
960        {
961            errors.push("[backup] section requires `backup = true` in [integrations]".to_string());
962            // No-op read so the binding isn't unused if all sub-checks
963            // below get gated out by serde defaults.
964            let _ = backup;
965        }
966        if let Some(ref backup) = self.backup {
967            for (label, hook) in [
968                ("pre_backup", &backup.pre_backup),
969                ("post_backup", &backup.post_backup),
970                ("pre_restore", &backup.pre_restore),
971                ("post_restore", &backup.post_restore),
972            ] {
973                if let Some(script) = hook
974                    && (script.is_empty() || script.contains('/') || script.contains(".."))
975                {
976                    errors.push(format!(
977                        "backup hook '{label}' must be a bare filename under configs/scripts/ \
978                         (got {script:?})"
979                    ));
980                }
981            }
982            for p in &backup.paths {
983                if p.is_empty() || p.starts_with('/') || p.contains("..") {
984                    errors.push(format!(
985                        "backup path {p:?} must be a relative path within the service home"
986                    ));
987                }
988            }
989        }
990
991        // --- Runtime / build consistency ---
992        // Make "native without a build target" and "podman with a build
993        // section" unrepresentable past load: a native service needs to know
994        // which binary to run; a podman service has no business declaring one.
995        match self.service.runtime {
996            Runtime::Native => match &self.service.run {
997                None => errors.push(
998                    "runtime = \"native\" requires a `run` command under [service]".to_string(),
999                ),
1000                Some(run) if run.trim().is_empty() => {
1001                    errors.push("[service].run must not be empty".to_string())
1002                }
1003                Some(_) => {}
1004            },
1005            Runtime::Podman => {
1006                if self.service.run.is_some() || self.service.build.is_some() {
1007                    errors.push(
1008                        "`run` / `build` are only valid for runtime = \"native\" services"
1009                            .to_string(),
1010                    );
1011                }
1012            }
1013        }
1014
1015        // --- Blue/green consistency ---
1016        // A blue/green deploy swaps a Caddy upstream between two instances, so
1017        // it needs (a) a port to route, and (b) a readiness probe to know the
1018        // standby is live before cutting over. Make a half-configured strategy
1019        // unrepresentable rather than letting it surface as a runtime surprise
1020        // mid-deploy. The Caddy-backed-exposure requirement is enforced at
1021        // install time (exposure is chosen by `ryra add`, not service.toml).
1022        if self.service.deploy == DeployStrategy::BlueGreen {
1023            if self.ports.is_empty() {
1024                errors.push(
1025                    "deploy = \"blue-green\" requires at least one [[ports]] entry to route"
1026                        .to_string(),
1027                );
1028            }
1029            match self.service.health_check.as_deref() {
1030                None => errors.push(
1031                    "deploy = \"blue-green\" requires a `health_check` path under [service]"
1032                        .to_string(),
1033                ),
1034                Some(p) if !p.starts_with('/') => errors.push(format!(
1035                    "`health_check` must be an absolute path starting with '/', got {p:?}"
1036                )),
1037                Some(_) => {}
1038            }
1039            if self.service.health_timeout == Some(0) {
1040                errors.push(
1041                    "`health_timeout` must be greater than 0 seconds (omit it for the default)"
1042                        .to_string(),
1043                );
1044            }
1045        }
1046
1047        if errors.is_empty() {
1048            Ok(())
1049        } else {
1050            Err(format!("{name}: {}", errors.join("; ")))
1051        }
1052    }
1053}
1054
1055/// Where an [`EnvVar`] is declared, used to locate it in validation errors. A
1056/// closed sum type rather than a free-form string, so a caller can't pass an
1057/// arbitrary label and [`check_env_var`] `match`es it to build the suffix.
1058enum EnvLoc<'a> {
1059    TopLevel,
1060    Group(&'a str),
1061    ChoiceOption { choice: &'a str, option: &'a str },
1062}
1063
1064impl std::fmt::Display for EnvLoc<'_> {
1065    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1066        match self {
1067            EnvLoc::TopLevel => Ok(()),
1068            EnvLoc::Group(g) => write!(f, " in group '{g}'"),
1069            EnvLoc::ChoiceOption { choice, option } => {
1070                write!(f, " in choice '{choice}' option '{option}'")
1071            }
1072        }
1073    }
1074}
1075
1076/// Name-format + kind-consistency check for a single `EnvVar`, shared by
1077/// top-level `[[env]]`, `[[env_group.env]]`, and `[[choice.option.env]]`. `loc`
1078/// is woven into each error so the offending declaration is locatable.
1079fn check_env_var(e: &EnvVar, loc: EnvLoc, errors: &mut Vec<String>) {
1080    if e.name.is_empty() {
1081        errors.push(format!("env var has empty name{loc}"));
1082    } else if !e
1083        .name
1084        .chars()
1085        .next()
1086        .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
1087    {
1088        errors.push(format!(
1089            "env var '{}'{loc} must start with a letter or _",
1090            e.name
1091        ));
1092    } else if !e
1093        .name
1094        .chars()
1095        .all(|c| c.is_ascii_alphanumeric() || c == '_')
1096    {
1097        errors.push(format!(
1098            "env var '{}'{loc} contains invalid characters (must match [A-Za-z0-9_])",
1099            e.name
1100        ));
1101    }
1102    if e.kind == EnvKind::Required && e.value.contains("{{secret.") {
1103        errors.push(format!(
1104            "env var '{}'{loc} is kind=required but has a secret template default; use kind=prompted or kind=default",
1105            e.name
1106        ));
1107    }
1108}
1109
1110/// Detect the current system architecture using OCI/Docker naming conventions.
1111pub fn current_architecture() -> Arch {
1112    match std::env::consts::ARCH {
1113        "x86_64" => Arch::Amd64,
1114        "aarch64" => Arch::Arm64,
1115        // Fallback: default to amd64 for unknown architectures.
1116        // The service's check_architecture() will catch unsupported ones.
1117        _ => Arch::Amd64,
1118    }
1119}
1120
1121#[cfg(test)]
1122mod backup_tests {
1123    use super::*;
1124
1125    fn parse(toml_src: &str) -> ServiceDef {
1126        toml::from_str(toml_src).expect("parse")
1127    }
1128
1129    #[test]
1130    fn blue_green_requires_health_check() {
1131        let svc = parse(
1132            r#"
1133[service]
1134name = "x"
1135description = "x"
1136deploy = "blue-green"
1137
1138[[ports]]
1139name = "http"
1140container_port = 8080
1141"#,
1142        );
1143        let err = svc.validate().expect_err("must reject");
1144        assert!(err.contains("health_check"), "got: {err}");
1145    }
1146
1147    #[test]
1148    fn blue_green_health_check_must_be_absolute_path() {
1149        let svc = parse(
1150            r#"
1151[service]
1152name = "x"
1153description = "x"
1154deploy = "blue-green"
1155health_check = "healthz"
1156
1157[[ports]]
1158name = "http"
1159container_port = 8080
1160"#,
1161        );
1162        let err = svc.validate().expect_err("must reject");
1163        assert!(err.contains("absolute path"), "got: {err}");
1164    }
1165
1166    #[test]
1167    fn blue_green_requires_a_port() {
1168        let svc = parse(
1169            r#"
1170[service]
1171name = "x"
1172description = "x"
1173deploy = "blue-green"
1174health_check = "/healthz"
1175"#,
1176        );
1177        let err = svc.validate().expect_err("must reject");
1178        assert!(err.contains("[[ports]]"), "got: {err}");
1179    }
1180
1181    #[test]
1182    fn blue_green_with_port_and_health_check_validates() {
1183        let svc = parse(
1184            r#"
1185[service]
1186name = "x"
1187description = "x"
1188deploy = "blue-green"
1189health_check = "/healthz"
1190
1191[[ports]]
1192name = "http"
1193container_port = 8080
1194"#,
1195        );
1196        assert!(svc.validate().is_ok());
1197        assert_eq!(svc.service.deploy, DeployStrategy::BlueGreen);
1198    }
1199
1200    #[test]
1201    fn health_timeout_defaults_to_120_and_honors_override() {
1202        let default = parse(
1203            r#"
1204[service]
1205name = "x"
1206description = "x"
1207deploy = "blue-green"
1208health_check = "/healthz"
1209
1210[[ports]]
1211name = "http"
1212container_port = 8080
1213"#,
1214        );
1215        assert_eq!(default.service.health_timeout, None);
1216        assert_eq!(default.service.health_timeout_secs(), 120);
1217
1218        let custom = parse(
1219            r#"
1220[service]
1221name = "x"
1222description = "x"
1223deploy = "blue-green"
1224health_check = "/healthz"
1225health_timeout = 300
1226
1227[[ports]]
1228name = "http"
1229container_port = 8080
1230"#,
1231        );
1232        assert_eq!(custom.service.health_timeout_secs(), 300);
1233        assert!(custom.validate().is_ok());
1234    }
1235
1236    #[test]
1237    fn health_timeout_zero_is_rejected() {
1238        let svc = parse(
1239            r#"
1240[service]
1241name = "x"
1242description = "x"
1243deploy = "blue-green"
1244health_check = "/healthz"
1245health_timeout = 0
1246
1247[[ports]]
1248name = "http"
1249container_port = 8080
1250"#,
1251        );
1252        let err = svc.validate().expect_err("must reject");
1253        assert!(err.contains("health_timeout"), "got: {err}");
1254    }
1255
1256    #[test]
1257    fn deploy_defaults_to_restart_and_is_omitted_when_serialized() {
1258        // No `deploy` line -> Restart, and a Restart strategy must not write a
1259        // redundant `deploy = "restart"` back out (skip_serializing_if).
1260        let svc = parse(
1261            r#"
1262[service]
1263name = "x"
1264description = "x"
1265
1266[[ports]]
1267name = "http"
1268container_port = 8080
1269"#,
1270        );
1271        assert_eq!(svc.service.deploy, DeployStrategy::Restart);
1272        let text = toml::to_string(&svc.service).expect("serialize ServiceMeta");
1273        assert!(!text.contains("deploy"), "got: {text}");
1274    }
1275
1276    #[test]
1277    fn tailscale_https_requires_exactly_one_root() {
1278        // Two tailscale-exposed ports but neither owns 443 → rejected.
1279        let svc = parse(
1280            r#"
1281[service]
1282name = "x"
1283description = "x"
1284
1285[[ports]]
1286name = "http"
1287container_port = 8080
1288tailscale_https = 8080
1289
1290[[ports]]
1291name = "photos"
1292container_port = 3000
1293tailscale_https = 3000
1294"#,
1295        );
1296        let err = svc.validate().expect_err("must reject");
1297        assert!(err.contains("tailscale_https = 443"), "got: {err}");
1298    }
1299
1300    #[test]
1301    fn tailscale_https_duplicate_port_rejected() {
1302        let svc = parse(
1303            r#"
1304[service]
1305name = "x"
1306description = "x"
1307
1308[[ports]]
1309name = "a"
1310container_port = 1
1311tailscale_https = 443
1312
1313[[ports]]
1314name = "b"
1315container_port = 2
1316tailscale_https = 443
1317"#,
1318        );
1319        let err = svc.validate().expect_err("must reject");
1320        assert!(err.contains("same tailscale_https"), "got: {err}");
1321    }
1322
1323    #[test]
1324    fn tailscale_https_one_root_plus_api_validates() {
1325        let svc = parse(
1326            r#"
1327[service]
1328name = "x"
1329description = "x"
1330
1331[[ports]]
1332name = "http"
1333container_port = 8080
1334tailscale_https = 8080
1335
1336[[ports]]
1337name = "photos"
1338container_port = 3000
1339tailscale_https = 443
1340"#,
1341        );
1342        svc.validate()
1343            .expect("one 443 root + one api port is valid");
1344    }
1345
1346    #[test]
1347    fn backup_defaults_to_false_when_omitted() {
1348        let svc = parse(
1349            r#"
1350[service]
1351name = "x"
1352description = "x"
1353"#,
1354        );
1355        assert!(!svc.integrations.backup);
1356        assert!(svc.backup.is_none());
1357        svc.validate().expect("default is valid");
1358    }
1359
1360    #[test]
1361    fn backup_section_alone_is_rejected_without_integration_flag() {
1362        let svc = parse(
1363            r#"
1364[service]
1365name = "x"
1366description = "x"
1367
1368[backup]
1369"#,
1370        );
1371        let err = svc.validate().expect_err("must reject");
1372        assert!(
1373            err.contains("backup = true"),
1374            "error mentions the required flag: {err}"
1375        );
1376    }
1377
1378    #[test]
1379    fn backup_supported_without_hooks_validates() {
1380        let svc = parse(
1381            r#"
1382[service]
1383name = "x"
1384description = "x"
1385
1386[integrations]
1387backup = true
1388"#,
1389        );
1390        assert!(svc.integrations.backup);
1391        assert!(svc.backup.is_none());
1392        svc.validate().expect("ok without [backup] table");
1393    }
1394
1395    #[test]
1396    fn backup_with_full_hooks_validates() {
1397        let svc = parse(
1398            r#"
1399[service]
1400name = "x"
1401description = "x"
1402
1403[integrations]
1404backup = true
1405
1406[backup]
1407paths = [".backup/db.sql.gz", "data"]
1408exclude = ["data/cache"]
1409pre_backup = "backup-pre.sh"
1410post_backup = "backup-post.sh"
1411pre_restore = "restore-pre.sh"
1412post_restore = "restore-post.sh"
1413"#,
1414        );
1415        svc.validate().expect("ok");
1416        let backup = svc.backup.as_ref().expect("section present");
1417        assert_eq!(backup.paths, vec![".backup/db.sql.gz", "data"]);
1418        assert_eq!(backup.pre_backup.as_deref(), Some("backup-pre.sh"));
1419    }
1420
1421    #[test]
1422    fn backup_hook_with_slash_is_rejected() {
1423        let svc = parse(
1424            r#"
1425[service]
1426name = "x"
1427description = "x"
1428
1429[integrations]
1430backup = true
1431
1432[backup]
1433pre_backup = "subdir/script.sh"
1434"#,
1435        );
1436        let err = svc.validate().expect_err("must reject");
1437        assert!(err.contains("pre_backup"), "{err}");
1438    }
1439
1440    #[test]
1441    fn backup_hook_with_dotdot_is_rejected() {
1442        let svc = parse(
1443            r#"
1444[service]
1445name = "x"
1446description = "x"
1447
1448[integrations]
1449backup = true
1450
1451[backup]
1452post_backup = "../escape.sh"
1453"#,
1454        );
1455        let err = svc.validate().expect_err("must reject");
1456        assert!(err.contains("post_backup"), "{err}");
1457    }
1458
1459    #[test]
1460    fn backup_absolute_path_is_rejected() {
1461        let svc = parse(
1462            r#"
1463[service]
1464name = "x"
1465description = "x"
1466
1467[integrations]
1468backup = true
1469
1470[backup]
1471paths = ["/etc/passwd"]
1472"#,
1473        );
1474        let err = svc.validate().expect_err("must reject");
1475        assert!(err.contains("/etc/passwd"), "{err}");
1476    }
1477
1478    #[test]
1479    fn backup_path_with_dotdot_is_rejected() {
1480        let svc = parse(
1481            r#"
1482[service]
1483name = "x"
1484description = "x"
1485
1486[integrations]
1487backup = true
1488
1489[backup]
1490paths = ["../../somewhere"]
1491"#,
1492        );
1493        let err = svc.validate().expect_err("must reject");
1494        assert!(err.contains("somewhere"), "{err}");
1495    }
1496}
1497
1498#[cfg(test)]
1499mod https_requirement_tests {
1500    use super::*;
1501
1502    fn parse(toml_src: &str) -> ServiceDef {
1503        toml::from_str(toml_src).expect("parse")
1504    }
1505
1506    /// Every shipped registry `service.toml` must parse and validate under the
1507    /// current schema. Guards against a core change (a new field, a stricter
1508    /// rule) silently breaking a catalog service. Skips gracefully if the
1509    /// registry dir isn't present (e.g. a packaged build of just the crate).
1510    #[test]
1511    fn all_registry_services_parse_and_validate() {
1512        let registry = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../registry");
1513        if !registry.is_dir() {
1514            eprintln!("registry dir not found ({}); skipping", registry.display());
1515            return;
1516        }
1517        let mut failures = Vec::new();
1518        let entries = std::fs::read_dir(&registry).expect("read registry dir");
1519        for entry in entries {
1520            let entry = entry.expect("dir entry");
1521            let svc_toml = entry.path().join("service.toml");
1522            if !svc_toml.is_file() {
1523                continue;
1524            }
1525            let name = entry.file_name().to_string_lossy().into_owned();
1526            let text = std::fs::read_to_string(&svc_toml).expect("read service.toml");
1527            match toml::from_str::<ServiceDef>(&text) {
1528                Ok(def) => {
1529                    if let Err(e) = def.validate() {
1530                        failures.push(format!("{name}: validate: {e}"));
1531                    }
1532                }
1533                Err(e) => failures.push(format!("{name}: parse: {e}")),
1534            }
1535        }
1536        assert!(
1537            failures.is_empty(),
1538            "registry service.toml failures:\n  {}",
1539            failures.join("\n  ")
1540        );
1541    }
1542
1543    #[test]
1544    fn never_service_stays_http() {
1545        assert!(!HttpsRequirement::Never.needs_https(false, None));
1546        // Even with --auth, a service that didn't opt into HTTPS stays HTTP.
1547        // This is the RFC 8252 loopback case: http://127.0.0.1 is a valid
1548        // OIDC redirect_uri and most services (forgejo, etc.) work fine
1549        // that way.
1550        assert!(!HttpsRequirement::Never.needs_https(true, None));
1551        // Explicit http:// URL also stays HTTP.
1552        assert!(!HttpsRequirement::Never.needs_https(true, Some("http://foo.example.com")));
1553    }
1554
1555    #[test]
1556    fn always_service_always_promotes() {
1557        assert!(HttpsRequirement::Always.needs_https(false, None));
1558        assert!(HttpsRequirement::Always.needs_https(false, Some("http://foo.example.com")));
1559    }
1560
1561    #[test]
1562    fn auth_service_promotes_only_with_auth() {
1563        // The regression this guards: `ryra add nextcloud --auth` without
1564        // --url used to quietly install over HTTP and the SSO button never
1565        // rendered (user_oidc refuses to show it without HTTPS).
1566        assert!(HttpsRequirement::Auth.needs_https(true, None));
1567        // Without --auth, even an `https = "auth"` service stays HTTP.
1568        assert!(!HttpsRequirement::Auth.needs_https(false, None));
1569    }
1570
1571    #[test]
1572    fn explicit_https_url_promotes() {
1573        assert!(HttpsRequirement::Never.needs_https(false, Some("https://foo.example.com")));
1574    }
1575
1576    // --- [[choice]] validation ---
1577
1578    const BILLING_CHOICE: &str = r#"
1579[service]
1580name = "x"
1581description = "x"
1582
1583[[choice]]
1584name = "billing"
1585prompt = "Billing mode"
1586default = "mock"
1587
1588[[choice.option]]
1589name = "live"
1590label = "Stripe"
1591[[choice.option.env]]
1592name = "BILLING_MODE"
1593value = "live"
1594[[choice.option.env]]
1595name = "STRIPE_SECRET_KEY"
1596value = ""
1597kind = "required"
1598
1599[[choice.option]]
1600name = "mock"
1601[[choice.option.env]]
1602name = "BILLING_MODE"
1603value = "mock"
1604"#;
1605
1606    #[test]
1607    fn valid_choice_validates() {
1608        parse(BILLING_CHOICE)
1609            .validate()
1610            .expect("a well-formed choice is valid");
1611    }
1612
1613    #[test]
1614    fn choice_option_carries_quadlets() {
1615        let def = parse(
1616            r#"
1617[service]
1618name = "x"
1619description = "x"
1620
1621[[choice]]
1622name = "database"
1623prompt = "Database"
1624default = "internal"
1625
1626[[choice.option]]
1627name = "internal"
1628quadlets = ["x-postgres.container"]
1629[[choice.option.env]]
1630name = "DATABASE_URL"
1631value = "postgres://ryra@postgres/x"
1632
1633[[choice.option]]
1634name = "external"
1635[[choice.option.env]]
1636name = "DATABASE_URL"
1637value = ""
1638kind = "required"
1639"#,
1640        );
1641        def.validate().expect("valid");
1642        let internal = &def.choices[0].options[0];
1643        assert_eq!(internal.quadlets, vec!["x-postgres.container".to_string()]);
1644        assert!(def.choices[0].options[1].quadlets.is_empty());
1645    }
1646
1647    #[test]
1648    fn sibling_options_may_reuse_an_env_name() {
1649        // Both `live` and `mock` set BILLING_MODE — allowed, since at most one
1650        // option is ever active.
1651        let def = parse(BILLING_CHOICE);
1652        let billing = &def.choices[0];
1653        assert!(
1654            billing
1655                .options
1656                .iter()
1657                .all(|o| o.env.iter().any(|e| e.name == "BILLING_MODE"))
1658        );
1659        def.validate().expect("sibling reuse is allowed");
1660    }
1661
1662    #[test]
1663    fn choice_needs_at_least_two_options() {
1664        let svc = parse(
1665            r#"
1666[service]
1667name = "x"
1668description = "x"
1669
1670[[choice]]
1671name = "billing"
1672prompt = "p"
1673default = "only"
1674
1675[[choice.option]]
1676name = "only"
1677"#,
1678        );
1679        let err = svc.validate().expect_err("one option is not a choice");
1680        assert!(err.contains("at least two"), "got: {err}");
1681    }
1682
1683    #[test]
1684    fn choice_default_must_name_an_option() {
1685        let svc = parse(
1686            r#"
1687[service]
1688name = "x"
1689description = "x"
1690
1691[[choice]]
1692name = "billing"
1693prompt = "p"
1694default = "nope"
1695
1696[[choice.option]]
1697name = "live"
1698[[choice.option]]
1699name = "mock"
1700"#,
1701        );
1702        let err = svc.validate().expect_err("bad default rejected");
1703        assert!(err.contains("names no option"), "got: {err}");
1704    }
1705
1706    #[test]
1707    fn duplicate_option_name_rejected() {
1708        let svc = parse(
1709            r#"
1710[service]
1711name = "x"
1712description = "x"
1713
1714[[choice]]
1715name = "billing"
1716prompt = "p"
1717default = "live"
1718
1719[[choice.option]]
1720name = "live"
1721[[choice.option]]
1722name = "live"
1723"#,
1724        );
1725        let err = svc.validate().expect_err("dup option rejected");
1726        assert!(err.contains("duplicate option"), "got: {err}");
1727    }
1728
1729    #[test]
1730    fn two_choices_sharing_an_env_name_collide() {
1731        // Different choices can both be active, so a shared name is a real
1732        // collision (unlike sibling options of one choice).
1733        let svc = parse(
1734            r#"
1735[service]
1736name = "x"
1737description = "x"
1738
1739[[choice]]
1740name = "a"
1741prompt = "p"
1742default = "one"
1743[[choice.option]]
1744name = "one"
1745[[choice.option.env]]
1746name = "SHARED"
1747value = "1"
1748[[choice.option]]
1749name = "two"
1750
1751[[choice]]
1752name = "b"
1753prompt = "p"
1754default = "one"
1755[[choice.option]]
1756name = "one"
1757[[choice.option.env]]
1758name = "SHARED"
1759value = "2"
1760[[choice.option]]
1761name = "two"
1762"#,
1763        );
1764        let err = svc.validate().expect_err("cross-choice collision rejected");
1765        assert!(err.contains("collides"), "got: {err}");
1766    }
1767
1768    #[test]
1769    fn choice_name_colliding_with_group_rejected() {
1770        let svc = parse(
1771            r#"
1772[service]
1773name = "x"
1774description = "x"
1775
1776[[env_group]]
1777name = "billing"
1778prompt = "p"
1779[[env_group.env]]
1780name = "FOO"
1781value = "1"
1782
1783[[choice]]
1784name = "billing"
1785prompt = "p"
1786default = "live"
1787[[choice.option]]
1788name = "live"
1789[[choice.option]]
1790name = "mock"
1791"#,
1792        );
1793        let err = svc.validate().expect_err("name clash rejected");
1794        assert!(err.contains("shares a name"), "got: {err}");
1795    }
1796}