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