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