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    #[serde(default)]
23    pub requires: Vec<ServiceRequirement>,
24    #[serde(default)]
25    pub mappings: Mappings,
26    #[serde(default)]
27    pub integrations: IntegrationFlags,
28    /// Roles this service can play for *other* services. The dual of
29    /// [`IntegrationFlags`] (which describes what this service consumes).
30    /// Drives capability-based dispatch — see [`crate::capability`].
31    #[serde(default)]
32    pub capabilities: Capabilities,
33    /// Backup configuration. Present only when the author has declared
34    /// `backup = true` in `[integrations]` and the service needs more
35    /// than the default "back up everything classified as data."
36    /// Carries hooks (pre/post dump) and exclude lists.
37    #[serde(default)]
38    pub backup: Option<BackupConfig>,
39    /// Prometheus-style metrics endpoint this service exposes. When set
40    /// and a metrics-store provider is installed, ryra writes a file_sd
41    /// scrape target and joins the service to the store's network.
42    #[serde(default)]
43    pub metrics: Option<MetricsDef>,
44}
45
46/// Where a service serves Prometheus-style metrics.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct MetricsDef {
49    /// Name of the `[[ports]]` entry the metrics endpoint listens on.
50    /// The scrape target uses that entry's *container* port — the store
51    /// reaches the service over the shared podman network, not the host.
52    pub port: String,
53    /// HTTP path of the endpoint.
54    #[serde(default = "default_metrics_path")]
55    pub path: String,
56    /// The service runs with `Network=host` (e.g. node-exporter, which
57    /// needs the real interfaces). It can't join the store's bridge
58    /// network, so the scrape target addresses the podman host gateway
59    /// (`host.containers.internal`) at the *resolved host port* instead
60    /// of container DNS.
61    #[serde(default)]
62    pub host_network: bool,
63}
64
65fn default_metrics_path() -> String {
66    "/metrics".to_string()
67}
68
69/// Capability declarations on a service.
70#[derive(Debug, Clone, Default, Serialize, Deserialize)]
71pub struct Capabilities {
72    /// Capabilities this service offers to other services.
73    #[serde(default)]
74    pub provides: Vec<Capability>,
75}
76
77/// System resource requirements for a service.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct Requirements {
80    /// RAM requirements in megabytes.
81    pub ram: RamRequirement,
82    /// Disk requirements in gigabytes.
83    #[serde(default)]
84    pub disk: Option<DiskRequirement>,
85}
86
87/// RAM requirement with minimum and recommended thresholds.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct RamRequirement {
90    /// Minimum RAM in MB — service may fail below this.
91    pub min: u64,
92    /// Recommended RAM in MB — service will run well at this level.
93    #[serde(default)]
94    pub recommended: Option<u64>,
95}
96
97/// Disk requirement in gigabytes.
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct DiskRequirement {
100    /// Minimum disk in GB — container images + data must fit.
101    pub min: u32,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct ServiceMeta {
106    pub name: String,
107    pub description: String,
108    /// Optional URL to documentation or project homepage.
109    #[serde(default)]
110    pub url: Option<String>,
111    #[serde(default)]
112    pub kind: ServiceKind,
113    /// Supported CPU architectures (e.g. ["amd64", "arm64"]).
114    /// Empty means all architectures are supported.
115    #[serde(default)]
116    pub architecture: Vec<Arch>,
117    /// Whether this service requires HTTPS to function.
118    #[serde(default)]
119    pub https: HttpsRequirement,
120    /// How this service runs: a podman container (default) or a native process
121    /// under systemd --user.
122    #[serde(default)]
123    pub runtime: Runtime,
124    /// `runtime = "native"` only: the command ryra runs as the service (the
125    /// unit's `ExecStart`), executed in the service's source dir. A binary
126    /// (`target/release/app`), an interpreter (`bun run src/index.ts`), or a
127    /// watcher (`bun --watch run …`) for save-and-reload. Required for native,
128    /// forbidden for podman (enforced in `validate()`).
129    #[serde(default)]
130    pub run: Option<String>,
131    /// `runtime = "native"` only: optional command run in the source dir before
132    /// the service starts and on every `ryra upgrade` (e.g. `cargo build
133    /// --release`, `bun install`). Omit when `run` needs no build step.
134    #[serde(default)]
135    pub build: Option<String>,
136    /// Free-text guidance printed once after a successful `ryra add` —
137    /// truly-unavoidable manual steps (initial web wizard, recommended
138    /// dashboard imports). Keep it short; everything automatable should
139    /// be automated instead.
140    #[serde(default)]
141    pub post_install: Option<String>,
142}
143
144/// What role this service plays in the system.
145#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
146#[serde(rename_all = "lowercase")]
147pub enum ServiceKind {
148    #[default]
149    Application,
150    Infrastructure,
151}
152
153/// How a service is realized on the host.
154#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
155#[serde(rename_all = "lowercase")]
156pub enum Runtime {
157    /// A rootless podman container via a quadlet (`Image=`). The default, and
158    /// what every catalog service uses.
159    #[default]
160    Podman,
161    /// A process run directly under `systemd --user`, no container. ryra runs
162    /// the service's `run` command in its source dir (after the optional
163    /// `build` step), with the same port/data/env contract a container gets.
164    Native,
165}
166
167impl Runtime {
168    /// Whether this is the default podman runtime. Used as a serde
169    /// `skip_serializing_if` so podman installs don't carry a redundant
170    /// `runtime = "podman"` in their metadata.
171    pub fn is_podman(&self) -> bool {
172        matches!(self, Runtime::Podman)
173    }
174}
175
176/// CPU architecture for container images.
177#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
178#[serde(rename_all = "lowercase")]
179pub enum Arch {
180    Amd64,
181    Arm64,
182}
183
184impl std::fmt::Display for Arch {
185    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186        match self {
187            Arch::Amd64 => write!(f, "amd64"),
188            Arch::Arm64 => write!(f, "arm64"),
189        }
190    }
191}
192
193/// Whether this service requires HTTPS to function.
194///
195/// Declarative, per-service. No magic derivation from other fields — a
196/// service that needs HTTPS must say so explicitly.
197///
198/// - `Never` (default): HTTP is fine. Per RFC 8252 loopback redirect URIs
199///   (`http://127.0.0.1`, `http://localhost`) are valid OIDC callbacks, so
200///   most services work over plain HTTP even with `--auth`.
201/// - `Auth`: HTTPS required when `--auth` is used. For services whose OIDC
202///   implementation rejects plain-HTTP even on loopback (e.g. nextcloud's
203///   `user_oidc` refuses to render the SSO button over HTTP).
204/// - `Always`: HTTPS required regardless of flags. For services that
205///   refuse HTTP outright (e.g. authelia, vaultwarden).
206#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
207#[serde(rename_all = "lowercase")]
208pub enum HttpsRequirement {
209    #[default]
210    Never,
211    Auth,
212    Always,
213}
214
215impl HttpsRequirement {
216    /// Decide whether an install must be promoted to HTTPS.
217    ///
218    /// HTTPS is required when any of these hold:
219    ///   1. The service declares `https = "always"`.
220    ///   2. The service declares `https = "auth"` AND the user chose OIDC
221    ///      auth (via `--auth` or the interactive prompt).
222    ///   3. The user passed an `https://...` URL explicitly.
223    pub fn needs_https(&self, auth_requested: bool, url: Option<&str>) -> bool {
224        matches!(self, HttpsRequirement::Always)
225            || (matches!(self, HttpsRequirement::Auth) && auth_requested)
226            || url.is_some_and(|u| u.starts_with("https://"))
227    }
228}
229
230/// Whether a port uses TCP or UDP.
231#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
232#[serde(rename_all = "lowercase")]
233pub enum PortProtocol {
234    #[default]
235    Tcp,
236    Udp,
237}
238
239impl std::fmt::Display for PortProtocol {
240    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
241        match self {
242            PortProtocol::Tcp => write!(f, "tcp"),
243            PortProtocol::Udp => write!(f, "udp"),
244        }
245    }
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize)]
249pub struct PortDef {
250    pub name: String,
251    pub container_port: u16,
252    /// Fixed host port (for privileged services like Caddy that need specific ports).
253    /// If not set, ryra allocates a port dynamically.
254    #[serde(default)]
255    pub host_port: Option<u16>,
256    #[serde(default)]
257    pub protocol: PortProtocol,
258    /// When set and the service is exposed with `--tailscale`, this port is
259    /// served over the service's Tailscale vIP on the given HTTPS port (e.g.
260    /// `443` for the web root, `8080` for an API). Tailnet-only `serve`
261    /// accepts arbitrary ports, so the value is usually the port's own number
262    /// (or `443` for the one port that should answer at the bare hostname).
263    /// Ports without this stay loopback-only. Reachable in templates via
264    /// `{{service.port_url.<name>}}`. Multi-port services (e.g. ente: a web
265    /// UI plus a separate API) need this so each endpoint gets its own URL.
266    #[serde(default)]
267    pub tailscale_https: Option<u16>,
268}
269
270/// How an env var is presented to the user during `ryra add`.
271///
272/// - `default`: static value or template (e.g. `{{secret.password}}`),
273///   not prompted — user can edit `.env` manually after install
274/// - `prompted`: shown during `ryra add` with a default value — optional
275///   but visible (e.g. API keys that can be left empty)
276/// - `required`: must be provided during `ryra add` — no usable default,
277///   blocks install if not provided. Tests must supply these via `env` overrides.
278#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
279#[serde(rename_all = "lowercase")]
280pub enum EnvKind {
281    /// Not prompted. Value is used as-is (may contain templates like `{{secret.*}}`).
282    #[default]
283    Default,
284    /// Prompted during `ryra add` with a default. User can accept or change.
285    Prompted,
286    /// Must be provided. No usable default — fails in non-interactive mode
287    /// unless supplied via env overrides.
288    Required,
289}
290
291/// Format of an env var's value — used for secret generation and input validation.
292#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
293#[serde(rename_all = "snake_case")]
294pub enum EnvFormat {
295    /// Free-form alphanumeric string (default).
296    #[default]
297    String,
298    /// Hexadecimal characters only.
299    Hex,
300    /// Standard base64 encoding of N random bytes (`length` = byte count,
301    /// default 32). Use for binary keys that the service base64-decodes to a
302    /// fixed byte length — e.g. Ente's libsodium keys (32-byte encryption,
303    /// 64-byte hash). A plain `string`/`hex` value decodes to the wrong length.
304    Base64,
305    /// URL-safe base64 (`-_` instead of `+/`) of N random bytes. Same use as
306    /// `base64`, but for services that decode with URL-safe base64 — e.g.
307    /// Ente's `jwt.secret` (Go `base64.URLEncoding`), which rejects `+`/`/`.
308    Base64Url,
309    /// UUID v4.
310    Uuid,
311    /// HS256-signed JWT. Requires `jwt_role` and `jwt_signing_key` on the env var.
312    JwtHs256,
313}
314
315#[derive(Debug, Clone, Serialize, Deserialize)]
316pub struct EnvVar {
317    pub name: String,
318    pub value: String,
319    #[serde(default)]
320    pub kind: EnvKind,
321    /// Prompt message shown during `ryra add` (for `prompted` and `required` kinds).
322    #[serde(default)]
323    pub prompt: Option<String>,
324    /// Value format — used to generate secrets and validate user input.
325    #[serde(default)]
326    pub format: EnvFormat,
327    /// Length for generated secrets. Ignored for `uuid` and `jwt_hs256` formats.
328    /// Defaults to 32 for `string`, 64 for `hex`.
329    #[serde(default)]
330    pub length: Option<u32>,
331    /// JSON payload claims for `jwt_hs256` format (e.g., `{"role": "anon", "iss": "supabase"}`).
332    /// `iat` and `exp` are added automatically if not present.
333    #[serde(default)]
334    pub jwt_claims: Option<std::collections::BTreeMap<std::string::String, serde_json::Value>>,
335    /// Secret name used as the HS256 signing key (e.g., "jwt_secret"). Required for `jwt_hs256` format.
336    #[serde(default)]
337    pub jwt_signing_key: Option<std::string::String>,
338}
339
340/// A user-toggled bundle of env vars. Enabling the group writes every
341/// member into `.env`; disabling it writes none of them.
342///
343/// Members reuse the full [`EnvVar`] shape — `kind = "default"` members are
344/// auto-included with their rendered template when the group is on,
345/// `prompted` members get shown with a default, `required` members must be
346/// supplied (interactively or via process env).
347#[derive(Debug, Clone, Serialize, Deserialize)]
348pub struct EnvGroup {
349    /// Identifier used by the `--enable <name>` CLI flag. Lowercase
350    /// snake_case by convention.
351    pub name: String,
352    /// Yes/no question shown during `ryra add` to toggle the group.
353    pub prompt: String,
354    #[serde(default)]
355    pub env: Vec<EnvVar>,
356}
357
358/// A service that must already be installed on the system before this one.
359///
360/// References separately-installed ryra services whose env vars
361/// and ports can be referenced via `{{services.<name>.*}}` templates.
362#[derive(Debug, Clone, Serialize, Deserialize)]
363pub struct ServiceRequirement {
364    pub service: String,
365}
366
367#[derive(Debug, Clone, Default, Serialize, Deserialize)]
368pub struct Mappings {
369    #[serde(default)]
370    pub smtp: BTreeMap<String, String>,
371    #[serde(default)]
372    pub auth: BTreeMap<String, String>,
373}
374
375/// What kind of auth integration a service supports.
376#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
377#[serde(rename_all = "kebab-case")]
378pub enum AuthKind {
379    /// Service handles OIDC auth itself (e.g. affine, forgejo).
380    Oidc,
381}
382
383impl std::fmt::Display for AuthKind {
384    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
385        match self {
386            AuthKind::Oidc => write!(f, "oidc"),
387        }
388    }
389}
390
391/// OIDC token endpoint authentication method for authelia client registration.
392#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
393#[serde(rename_all = "snake_case")]
394pub enum TokenAuthMethod {
395    #[default]
396    ClientSecretPost,
397    ClientSecretBasic,
398    /// PKCE public client — no client_secret sent. Used by apps like Zammad
399    /// that only support the public-client + PKCE OIDC flow.
400    None,
401}
402
403impl TokenAuthMethod {
404    pub fn as_str(&self) -> &'static str {
405        match self {
406            TokenAuthMethod::ClientSecretPost => "client_secret_post",
407            TokenAuthMethod::ClientSecretBasic => "client_secret_basic",
408            TokenAuthMethod::None => "none",
409        }
410    }
411}
412
413#[derive(Debug, Clone, Serialize, Deserialize)]
414pub struct IntegrationFlags {
415    /// Auth types this service supports. Empty = no auth support.
416    #[serde(default)]
417    pub auth: Vec<AuthKind>,
418    /// OIDC token endpoint auth method for authelia client registration.
419    #[serde(default)]
420    pub token_auth_method: TokenAuthMethod,
421    /// OIDC callback path suffixes registered with the auth provider.
422    /// Appended to the service's base URL(s) to form redirect_uris.
423    #[serde(default)]
424    pub oidc_callbacks: Vec<String>,
425    #[serde(default = "default_true")]
426    pub smtp: bool,
427    /// True if the service author has certified this service can be
428    /// backed up safely. The default is `false` (explicit opt-in)
429    /// because the worst failure mode is a backup that takes cleanly
430    /// but won't restore (e.g. forgot to write a pg_dump hook), so
431    /// authors must consciously declare support.
432    ///
433    /// When `true`, an accompanying `[backup]` section MAY provide
434    /// hooks and excludes; when absent, the default behaviour is to
435    /// back up every top-level child of the service home dir that the
436    /// classifier marks as data.
437    #[serde(default)]
438    pub backup: bool,
439}
440
441impl Default for IntegrationFlags {
442    fn default() -> Self {
443        Self {
444            auth: vec![],
445            token_auth_method: TokenAuthMethod::default(),
446            oidc_callbacks: vec![],
447            smtp: true,
448            backup: false,
449        }
450    }
451}
452
453fn default_true() -> bool {
454    true
455}
456
457/// Per-service backup configuration. Present only when the service's
458/// `[integrations]` section sets `backup = true` AND the service needs
459/// non-default behaviour (excludes or hooks).
460///
461/// Hooks are filenames inside `configs/scripts/` (same convention as
462/// the existing `ExecStartPost=` scripts). They run with the same env
463/// as those scripts: `$SERVICE_HOME` plus everything in the service's
464/// `.env` file.
465///
466/// Pre/post hooks form a pair around the operation:
467///
468/// ```text
469/// backup:  [pre_backup]  -> restic snapshot   -> [post_backup]
470/// restore: [pre_restore] -> restic restore    -> [post_restore]
471/// ```
472///
473/// Hooks must dump to `$SERVICE_HOME/.backup/` (a sibling of `data/`)
474/// so it's clear which files are user-owned data versus snapshot
475/// artefacts. Listing `.backup/<file>` in `paths` is required if the
476/// hook writes one; nothing is implicitly included.
477#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
478pub struct BackupConfig {
479    /// Explicit list of paths (relative to service home) to include in
480    /// the snapshot. When empty, the default is "every top-level child
481    /// of the service home dir that the classifier marks as data."
482    #[serde(default)]
483    pub paths: Vec<String>,
484    /// Restic-style exclude patterns relative to service home.
485    /// Useful for skipping caches, previews, transcoding artefacts.
486    #[serde(default)]
487    pub exclude: Vec<String>,
488    /// Script filename (in `configs/scripts/`) run before the restic
489    /// snapshot. Typically dumps a database to `$SERVICE_HOME/.backup/`.
490    #[serde(default)]
491    pub pre_backup: Option<String>,
492    /// Script filename run after a successful restic snapshot.
493    /// Typically cleans up `$SERVICE_HOME/.backup/`.
494    #[serde(default)]
495    pub post_backup: Option<String>,
496    /// Script filename run before restoring (typically stops the
497    /// service and wipes the live data dir).
498    #[serde(default)]
499    pub pre_restore: Option<String>,
500    /// Script filename run after restoring (typically imports the
501    /// dump back into the live database and restarts the service).
502    #[serde(default)]
503    pub post_restore: Option<String>,
504}
505
506// ---------------------------------------------------------------------------
507// Validation
508// ---------------------------------------------------------------------------
509
510impl ServiceDef {
511    /// Check if this service supports the current system architecture.
512    /// Returns None if supported (or no restriction), Some(error) if not.
513    pub fn check_architecture(&self) -> Option<String> {
514        if self.service.architecture.is_empty() {
515            return None;
516        }
517        let current = current_architecture();
518        if self.service.architecture.contains(&current) {
519            None
520        } else {
521            let supported: Vec<_> = self
522                .service
523                .architecture
524                .iter()
525                .map(|a| a.to_string())
526                .collect();
527            Some(format!(
528                "{} only supports {} — this system is {current}",
529                self.service.name,
530                supported.join(", "),
531            ))
532        }
533    }
534
535    /// Returns env var names that are required — must be provided during install.
536    pub fn required_env_vars(&self) -> Vec<&str> {
537        self.env
538            .iter()
539            .filter(|e| e.kind == EnvKind::Required)
540            .map(|e| e.name.as_str())
541            .collect()
542    }
543
544    /// Validate structural invariants that serde can't enforce.
545    /// Called once after deserialization — if this returns Ok, the definition
546    /// is safe to use without further checks.
547    pub fn validate(&self) -> Result<(), String> {
548        let name = &self.service.name;
549        let mut errors: Vec<String> = Vec::new();
550
551        // --- Duplicate names ---
552
553        let mut seen_ports = std::collections::HashSet::new();
554        let mut seen_ts_https = std::collections::HashSet::new();
555        for p in &self.ports {
556            if !seen_ports.insert(&p.name) {
557                errors.push(format!("duplicate port name '{}'", p.name));
558            }
559            // `container_port = 0` is the "fill in later" placeholder `ryra init`
560            // writes for a blank port. Refuse to install until it's a real port.
561            if p.container_port == 0 {
562                errors.push(format!(
563                    "port '{}' has container_port = 0 — fill in the port your service listens on",
564                    p.name
565                ));
566            }
567            // Two ports can't be served on the same Tailscale HTTPS port —
568            // the second `tailscale serve --https=<p>` would clobber the first.
569            if let Some(https) = p.tailscale_https
570                && !seen_ts_https.insert(https)
571            {
572                errors.push(format!(
573                    "two ports map to the same tailscale_https port {https}"
574                ));
575            }
576        }
577        // If any port opts into Tailscale exposure, exactly one must own 443 —
578        // that's the web root answering at the bare `<svc>.<tailnet>.ts.net`.
579        let ts_ports: Vec<&PortDef> = self
580            .ports
581            .iter()
582            .filter(|p| p.tailscale_https.is_some())
583            .collect();
584        if !ts_ports.is_empty()
585            && ts_ports
586                .iter()
587                .filter(|p| p.tailscale_https == Some(443))
588                .count()
589                != 1
590        {
591            errors.push(
592                "services exposing ports over Tailscale must mark exactly one port \
593                 tailscale_https = 443 (the web root)"
594                    .to_string(),
595            );
596        }
597
598        // [metrics] must reference a declared port — the scrape target is
599        // built from that entry's container_port.
600        if let Some(metrics) = &self.metrics
601            && !self.ports.iter().any(|p| p.name == metrics.port)
602        {
603            errors.push(format!(
604                "[metrics] references port '{}' but no [[ports]] entry has that name",
605                metrics.port
606            ));
607        }
608
609        // Every env var name (top-level + every group member) must be unique
610        // across the whole service — podman's .env is a flat keyspace so two
611        // FOO= lines would be ambiguous.
612        let mut seen_envs: std::collections::HashSet<&str> = std::collections::HashSet::new();
613        for e in &self.env {
614            if !seen_envs.insert(&e.name) {
615                errors.push(format!("duplicate env var name '{}'", e.name));
616            }
617        }
618        for g in &self.env_groups {
619            for e in &g.env {
620                if !seen_envs.insert(&e.name) {
621                    errors.push(format!(
622                        "env var '{}' in group '{}' collides with another env var",
623                        e.name, g.name
624                    ));
625                }
626            }
627        }
628
629        // --- Env var name format + kind consistency ---
630
631        for e in &self.env {
632            check_env_var(e, None, &mut errors);
633        }
634
635        // --- Env group names + members ---
636
637        let mut seen_groups = std::collections::HashSet::new();
638        for g in &self.env_groups {
639            if !seen_groups.insert(&g.name) {
640                errors.push(format!("duplicate env_group name '{}'", g.name));
641            }
642            if g.name.is_empty() {
643                errors.push("env_group has empty name".to_string());
644            } else if !g
645                .name
646                .chars()
647                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
648            {
649                errors.push(format!(
650                    "env_group '{}' must be lowercase snake_case ([a-z0-9_])",
651                    g.name
652                ));
653            }
654            if g.prompt.is_empty() {
655                errors.push(format!("env_group '{}' has empty prompt", g.name));
656            }
657            if g.env.is_empty() {
658                errors.push(format!("env_group '{}' has no env vars", g.name));
659            }
660            for e in &g.env {
661                check_env_var(e, Some(&g.name), &mut errors);
662            }
663        }
664
665        // --- RAM requirements consistency ---
666
667        if let Some(ref req) = self.requirements
668            && let Some(rec) = req.ram.recommended
669            && rec < req.ram.min
670        {
671            errors.push(format!(
672                "recommended RAM ({rec}MB) is less than minimum ({}MB)",
673                req.ram.min
674            ));
675        }
676
677        // --- Backup consistency ---
678        //
679        // The `[backup]` section is only meaningful when the author has
680        // certified the service is backup-safe via `backup = true`. If
681        // they wrote hooks/excludes without flipping the flag we'd
682        // silently ship a service whose backup support is half-declared,
683        // so reject it loudly.
684        if let Some(ref backup) = self.backup
685            && !self.integrations.backup
686        {
687            errors.push("[backup] section requires `backup = true` in [integrations]".to_string());
688            // No-op read so the binding isn't unused if all sub-checks
689            // below get gated out by serde defaults.
690            let _ = backup;
691        }
692        if let Some(ref backup) = self.backup {
693            for (label, hook) in [
694                ("pre_backup", &backup.pre_backup),
695                ("post_backup", &backup.post_backup),
696                ("pre_restore", &backup.pre_restore),
697                ("post_restore", &backup.post_restore),
698            ] {
699                if let Some(script) = hook
700                    && (script.is_empty() || script.contains('/') || script.contains(".."))
701                {
702                    errors.push(format!(
703                        "backup hook '{label}' must be a bare filename under configs/scripts/ \
704                         (got {script:?})"
705                    ));
706                }
707            }
708            for p in &backup.paths {
709                if p.is_empty() || p.starts_with('/') || p.contains("..") {
710                    errors.push(format!(
711                        "backup path {p:?} must be a relative path within the service home"
712                    ));
713                }
714            }
715        }
716
717        // --- Runtime / build consistency ---
718        // Make "native without a build target" and "podman with a build
719        // section" unrepresentable past load: a native service needs to know
720        // which binary to run; a podman service has no business declaring one.
721        match self.service.runtime {
722            Runtime::Native => match &self.service.run {
723                None => errors.push(
724                    "runtime = \"native\" requires a `run` command under [service]".to_string(),
725                ),
726                Some(run) if run.trim().is_empty() => {
727                    errors.push("[service].run must not be empty".to_string())
728                }
729                Some(_) => {}
730            },
731            Runtime::Podman => {
732                if self.service.run.is_some() || self.service.build.is_some() {
733                    errors.push(
734                        "`run` / `build` are only valid for runtime = \"native\" services"
735                            .to_string(),
736                    );
737                }
738            }
739        }
740
741        if errors.is_empty() {
742            Ok(())
743        } else {
744            Err(format!("{name}: {}", errors.join("; ")))
745        }
746    }
747}
748
749/// Shared name-format + kind-consistency check for a single `EnvVar`, used
750/// for both top-level `[[env]]` entries and `[[env_group.env]]` members.
751/// `group` is `Some(group_name)` for member vars — it's used to make error
752/// messages locate the offending declaration.
753fn check_env_var(e: &EnvVar, group: Option<&str>, errors: &mut Vec<String>) {
754    let where_ = match group {
755        Some(g) => format!(" in group '{g}'"),
756        None => String::new(),
757    };
758    if e.name.is_empty() {
759        errors.push(format!("env var has empty name{where_}"));
760    } else if !e
761        .name
762        .chars()
763        .next()
764        .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
765    {
766        errors.push(format!(
767            "env var '{}'{where_} must start with a letter or _",
768            e.name
769        ));
770    } else if !e
771        .name
772        .chars()
773        .all(|c| c.is_ascii_alphanumeric() || c == '_')
774    {
775        errors.push(format!(
776            "env var '{}'{where_} contains invalid characters — must match [A-Za-z0-9_]",
777            e.name
778        ));
779    }
780    if e.kind == EnvKind::Required && e.value.contains("{{secret.") {
781        errors.push(format!(
782            "env var '{}'{where_} is kind=required but has a secret template default — use kind=prompted or kind=default",
783            e.name
784        ));
785    }
786}
787
788/// Detect the current system architecture using OCI/Docker naming conventions.
789pub fn current_architecture() -> Arch {
790    match std::env::consts::ARCH {
791        "x86_64" => Arch::Amd64,
792        "aarch64" => Arch::Arm64,
793        // Fallback: default to amd64 for unknown architectures.
794        // The service's check_architecture() will catch unsupported ones.
795        _ => Arch::Amd64,
796    }
797}
798
799#[cfg(test)]
800mod backup_tests {
801    use super::*;
802
803    fn parse(toml_src: &str) -> ServiceDef {
804        toml::from_str(toml_src).expect("parse")
805    }
806
807    #[test]
808    fn tailscale_https_requires_exactly_one_root() {
809        // Two tailscale-exposed ports but neither owns 443 → rejected.
810        let svc = parse(
811            r#"
812[service]
813name = "x"
814description = "x"
815
816[[ports]]
817name = "http"
818container_port = 8080
819tailscale_https = 8080
820
821[[ports]]
822name = "photos"
823container_port = 3000
824tailscale_https = 3000
825"#,
826        );
827        let err = svc.validate().expect_err("must reject");
828        assert!(err.contains("tailscale_https = 443"), "got: {err}");
829    }
830
831    #[test]
832    fn tailscale_https_duplicate_port_rejected() {
833        let svc = parse(
834            r#"
835[service]
836name = "x"
837description = "x"
838
839[[ports]]
840name = "a"
841container_port = 1
842tailscale_https = 443
843
844[[ports]]
845name = "b"
846container_port = 2
847tailscale_https = 443
848"#,
849        );
850        let err = svc.validate().expect_err("must reject");
851        assert!(err.contains("same tailscale_https"), "got: {err}");
852    }
853
854    #[test]
855    fn tailscale_https_one_root_plus_api_validates() {
856        let svc = parse(
857            r#"
858[service]
859name = "x"
860description = "x"
861
862[[ports]]
863name = "http"
864container_port = 8080
865tailscale_https = 8080
866
867[[ports]]
868name = "photos"
869container_port = 3000
870tailscale_https = 443
871"#,
872        );
873        svc.validate()
874            .expect("one 443 root + one api port is valid");
875    }
876
877    #[test]
878    fn backup_defaults_to_false_when_omitted() {
879        let svc = parse(
880            r#"
881[service]
882name = "x"
883description = "x"
884"#,
885        );
886        assert!(!svc.integrations.backup);
887        assert!(svc.backup.is_none());
888        svc.validate().expect("default is valid");
889    }
890
891    #[test]
892    fn backup_section_alone_is_rejected_without_integration_flag() {
893        let svc = parse(
894            r#"
895[service]
896name = "x"
897description = "x"
898
899[backup]
900"#,
901        );
902        let err = svc.validate().expect_err("must reject");
903        assert!(
904            err.contains("backup = true"),
905            "error mentions the required flag: {err}"
906        );
907    }
908
909    #[test]
910    fn backup_supported_without_hooks_validates() {
911        let svc = parse(
912            r#"
913[service]
914name = "x"
915description = "x"
916
917[integrations]
918backup = true
919"#,
920        );
921        assert!(svc.integrations.backup);
922        assert!(svc.backup.is_none());
923        svc.validate().expect("ok without [backup] table");
924    }
925
926    #[test]
927    fn backup_with_full_hooks_validates() {
928        let svc = parse(
929            r#"
930[service]
931name = "x"
932description = "x"
933
934[integrations]
935backup = true
936
937[backup]
938paths = [".backup/db.sql.gz", "data"]
939exclude = ["data/cache"]
940pre_backup = "backup-pre.sh"
941post_backup = "backup-post.sh"
942pre_restore = "restore-pre.sh"
943post_restore = "restore-post.sh"
944"#,
945        );
946        svc.validate().expect("ok");
947        let backup = svc.backup.as_ref().expect("section present");
948        assert_eq!(backup.paths, vec![".backup/db.sql.gz", "data"]);
949        assert_eq!(backup.pre_backup.as_deref(), Some("backup-pre.sh"));
950    }
951
952    #[test]
953    fn backup_hook_with_slash_is_rejected() {
954        let svc = parse(
955            r#"
956[service]
957name = "x"
958description = "x"
959
960[integrations]
961backup = true
962
963[backup]
964pre_backup = "subdir/script.sh"
965"#,
966        );
967        let err = svc.validate().expect_err("must reject");
968        assert!(err.contains("pre_backup"), "{err}");
969    }
970
971    #[test]
972    fn backup_hook_with_dotdot_is_rejected() {
973        let svc = parse(
974            r#"
975[service]
976name = "x"
977description = "x"
978
979[integrations]
980backup = true
981
982[backup]
983post_backup = "../escape.sh"
984"#,
985        );
986        let err = svc.validate().expect_err("must reject");
987        assert!(err.contains("post_backup"), "{err}");
988    }
989
990    #[test]
991    fn backup_absolute_path_is_rejected() {
992        let svc = parse(
993            r#"
994[service]
995name = "x"
996description = "x"
997
998[integrations]
999backup = true
1000
1001[backup]
1002paths = ["/etc/passwd"]
1003"#,
1004        );
1005        let err = svc.validate().expect_err("must reject");
1006        assert!(err.contains("/etc/passwd"), "{err}");
1007    }
1008
1009    #[test]
1010    fn backup_path_with_dotdot_is_rejected() {
1011        let svc = parse(
1012            r#"
1013[service]
1014name = "x"
1015description = "x"
1016
1017[integrations]
1018backup = true
1019
1020[backup]
1021paths = ["../../somewhere"]
1022"#,
1023        );
1024        let err = svc.validate().expect_err("must reject");
1025        assert!(err.contains("somewhere"), "{err}");
1026    }
1027}
1028
1029#[cfg(test)]
1030mod https_requirement_tests {
1031    use super::*;
1032
1033    #[test]
1034    fn never_service_stays_http() {
1035        assert!(!HttpsRequirement::Never.needs_https(false, None));
1036        // Even with --auth, a service that didn't opt into HTTPS stays HTTP.
1037        // This is the RFC 8252 loopback case: http://127.0.0.1 is a valid
1038        // OIDC redirect_uri and most services (forgejo, etc.) work fine
1039        // that way.
1040        assert!(!HttpsRequirement::Never.needs_https(true, None));
1041        // Explicit http:// URL also stays HTTP.
1042        assert!(!HttpsRequirement::Never.needs_https(true, Some("http://foo.example.com")));
1043    }
1044
1045    #[test]
1046    fn always_service_always_promotes() {
1047        assert!(HttpsRequirement::Always.needs_https(false, None));
1048        assert!(HttpsRequirement::Always.needs_https(false, Some("http://foo.example.com")));
1049    }
1050
1051    #[test]
1052    fn auth_service_promotes_only_with_auth() {
1053        // The regression this guards: `ryra add nextcloud --auth` without
1054        // --url used to quietly install over HTTP and the SSO button never
1055        // rendered (user_oidc refuses to show it without HTTPS).
1056        assert!(HttpsRequirement::Auth.needs_https(true, None));
1057        // Without --auth, even an `https = "auth"` service stays HTTP.
1058        assert!(!HttpsRequirement::Auth.needs_https(false, None));
1059    }
1060
1061    #[test]
1062    fn explicit_https_url_promotes() {
1063        assert!(HttpsRequirement::Never.needs_https(false, Some("https://foo.example.com")));
1064    }
1065}