Skip to main content

ryra_core/generate/
mod.rs

1pub mod bundle;
2pub mod context;
3pub mod template;
4use std::collections::{BTreeMap, BTreeSet};
5use std::path::PathBuf;
6
7use crate::config::schema::Config;
8use crate::error::{Error, Result};
9use crate::exposure::Exposure;
10use crate::registry::service_def::{AuthKind, EnvKind, EnvVar, PortDef, ServiceDef};
11
12#[derive(Debug)]
13pub struct GeneratedFile {
14    pub path: PathBuf,
15    pub content: String,
16}
17
18/// Parameters for [`generate_env`].
19pub struct GenerateEnvParams<'a> {
20    pub config: &'a Config,
21    pub service_def: &'a ServiceDef,
22    /// The auth kind the user chose to enable, if any.
23    pub auth_kind: Option<&'a AuthKind>,
24    /// Primary host port (for `service.url` / `service.port` templating).
25    pub host_port: Option<u16>,
26    /// Per-port resolved host ports, keyed by port name (e.g. "http", "smtp").
27    /// Used to emit `PORT_*` lines in the .env file — each entry here
28    /// corresponds to one `[[ports]]` definition in service.toml.
29    pub resolved_ports: &'a [(String, u16)],
30    pub env_overrides: &'a BTreeMap<String, String>,
31    /// How the service is exposed; its URL (if any) feeds templates like
32    /// `{{service.external_url}}` / `{{service.domain}}`.
33    pub exposure: &'a Exposure,
34    /// Additional env vars to append to the .env file (e.g., CA cert trust vars).
35    pub extra_env: BTreeMap<String, String>,
36    /// Pre-built template context. When provided, secrets and auth credentials
37    /// from this context are reused instead of generating fresh ones. This
38    /// ensures the values shown during interactive prompts match what gets
39    /// written to the .env file.
40    pub pre_built_ctx: Option<BTreeMap<String, String>>,
41    /// Whether this service should use the globally configured SMTP. When
42    /// false, smtp.* is left out of the context and [mappings.smtp] is skipped
43    /// — lets the user opt a single service out of email without clearing the
44    /// global SMTP config.
45    pub enable_smtp: bool,
46    /// Names of `[[env_group]]` entries the user toggled on. Members of
47    /// groups not listed here are fully omitted from the generated `.env`.
48    pub enabled_groups: &'a BTreeSet<String>,
49    /// `[[choice]]` selections (`choice name -> option name`). Only the
50    /// selected option's members are written; absent choices fall back to
51    /// their declared `default`.
52    pub selected_choices: &'a BTreeMap<String, String>,
53    /// Raw contents of the service's existing on-disk `.env`, when one is
54    /// already present (a re-add, or a hand-authored "bring your own" file).
55    /// The generated env is *merged into* this rather than overwriting it:
56    /// existing lines, comments, and keys the registry doesn't know about are
57    /// preserved; only keys the user set this run (`env_overrides`) are
58    /// updated in place; new declared keys are appended. `None` for a fresh
59    /// install (no file yet) — the generated content is written as-is.
60    pub existing_env_file: Option<&'a str>,
61    /// Skip-setup: render an unset `Required` var that is a member of an
62    /// enabled group / selected choice to an empty value instead of erroring,
63    /// so an install can proceed with the operator filling the blanks in
64    /// `.env` afterwards. `false` keeps the strict default (a missing required
65    /// member is a hard error). A top-level required var renders empty either
66    /// way — it's gated at the CLI prompt layer, not here.
67    pub allow_unset_required: bool,
68}
69
70/// Result of generating env for a service.
71pub struct EnvOutput {
72    pub env_file: GeneratedFile,
73    /// The template context used during generation (for auth registration, etc.).
74    pub ctx: BTreeMap<String, String>,
75}
76
77/// Generate the .env file for a service based on its definition and context.
78pub fn generate_env(params: GenerateEnvParams<'_>) -> Result<EnvOutput> {
79    let name = &params.service_def.service.name;
80
81    // Always build a fresh context with the now-known host_port. Overlay
82    // secret.* and auth.* entries from the pre-built context so randomly
83    // generated values the user saw during prompts stay stable.
84    let mut ctx = context::build_context(
85        params.config,
86        params.service_def,
87        params.host_port,
88        params.auth_kind,
89        params.exposure,
90        params.enable_smtp,
91    )?;
92    if let Some(prebuilt) = params.pre_built_ctx {
93        for (key, value) in prebuilt {
94            if key.starts_with("secret.") || key.starts_with("auth.") {
95                ctx.insert(key, value);
96            }
97        }
98    }
99    // Effective ports = top-level plus the selected choice option's gated ports
100    // (so a gated port's port_url resolves; mirrors the allocator in lib.rs).
101    let mut eff_ports: Vec<&PortDef> = params.service_def.ports.iter().collect();
102    for choice in &params.service_def.choices {
103        let sel = params
104            .selected_choices
105            .get(&choice.name)
106            .unwrap_or(&choice.default);
107        if let Some(opt) = choice.options.iter().find(|o| &o.name == sel) {
108            eff_ports.extend(opt.ports.iter());
109        }
110    }
111    insert_port_urls(
112        &mut ctx,
113        &eff_ports,
114        params.resolved_ports,
115        params.exposure.url(),
116    );
117
118    let rendered_env = render_env_vars(
119        params.service_def,
120        &ctx,
121        params.env_overrides,
122        params.auth_kind,
123        params.enabled_groups,
124        params.selected_choices,
125        params.allow_unset_required,
126    )?;
127
128    // Ordered KEY=VALUE pairs the registry render produces: declared vars,
129    // ryra's own SERVICE_* lines, CA-trust/extra vars, then any operator keys
130    // the user supplied this run (e.g. via --env-file) that the registry
131    // doesn't declare — so those aren't silently dropped.
132    let home_dir = crate::service_home(name)?;
133    let generated = build_env_pairs(
134        &home_dir,
135        &rendered_env,
136        params.resolved_ports,
137        &params.extra_env,
138        params.env_overrides,
139    );
140
141    // Keys the user set explicitly this run win over what's on disk; every
142    // other existing line (untouched values, comments, undeclared operator
143    // vars) is preserved by the merge.
144    let explicit: BTreeSet<&str> = params.env_overrides.keys().map(String::as_str).collect();
145    let content = merge_env_file(params.existing_env_file, &generated, &explicit);
146    let env_file = GeneratedFile {
147        path: home_dir.join(".env"),
148        content,
149    };
150
151    Ok(EnvOutput { env_file, ctx })
152}
153
154/// Merge the registry-rendered env into a service's existing `.env`.
155///
156/// The `.env` carries runtime-rotated secrets and operator-authored keys the
157/// registry never sees, so a re-render must never blindly overwrite it. With
158/// `existing` present (a re-add or a hand-authored file) we walk the file
159/// line by line: comments and blanks pass through verbatim, a key the user set
160/// this run (`explicit`) is updated in place, and every other line is kept as
161/// is. Declared keys absent from the file are appended. With `existing` `None`
162/// (a fresh install, no file yet) the generated content is written as-is.
163fn merge_env_file(
164    existing: Option<&str>,
165    generated: &[(String, String)],
166    explicit: &BTreeSet<&str>,
167) -> String {
168    let render_fresh = || {
169        generated
170            .iter()
171            .map(|(k, v)| format!("{k}={v}"))
172            .collect::<Vec<_>>()
173            .join("\n")
174            + "\n"
175    };
176    let Some(existing) = existing else {
177        return render_fresh();
178    };
179    let gen_map: BTreeMap<&str, &str> = generated
180        .iter()
181        .map(|(k, v)| (k.as_str(), v.as_str()))
182        .collect();
183    let mut out: Vec<String> = Vec::new();
184    let mut seen: BTreeSet<&str> = BTreeSet::new();
185    for line in existing.lines() {
186        let trimmed = line.trim_start();
187        if trimmed.is_empty() || trimmed.starts_with('#') {
188            out.push(line.to_string());
189            continue;
190        }
191        let Some((raw_key, _)) = line.split_once('=') else {
192            // Not a KEY=VALUE line — preserve verbatim.
193            out.push(line.to_string());
194            continue;
195        };
196        let key = raw_key.trim();
197        seen.insert(key);
198        // Only a key the user set this run replaces its on-disk value; an
199        // untouched key keeps whatever's there (a rotated secret, a manual edit).
200        if explicit.contains(key)
201            && let Some(value) = gen_map.get(key)
202        {
203            out.push(format!("{key}={value}"));
204            continue;
205        }
206        out.push(line.to_string());
207    }
208    // Append declared / SERVICE_* / operator keys the file doesn't have yet.
209    for (key, value) in generated {
210        if !seen.contains(key.as_str()) {
211            out.push(format!("{key}={value}"));
212        }
213    }
214    out.join("\n") + "\n"
215}
216
217/// Insert `service.port_url.<name>` for every declared port — the URL at
218/// which that specific port is reachable from a browser.
219///
220/// For single-endpoint services every port resolves to `external_url`.
221/// Multi-port services (ente: web UI on 443, API on 8080, served on separate
222/// Tailscale HTTPS ports) get a distinct URL per port, so a template like
223/// `ENTE_API_ORIGIN = {{service.port_url.http}}` points at the API endpoint
224/// while the bare hostname serves the UI — in every exposure mode (loopback,
225/// raw `--url`, or `--tailscale`).
226fn insert_port_urls(
227    ctx: &mut BTreeMap<String, String>,
228    // Effective ports: top-level plus the selected choice option's, so a gated
229    // port (e.g. ente's bundled minio) gets `service.port_url.*` too.
230    ports: &[&PortDef],
231    resolved_ports: &[(String, u16)],
232    url: Option<&str>,
233) {
234    // Bare allocated host port per name, for templating into values like a
235    // DATABASE_URL that points at a gated container's published loopback port
236    // (`@127.0.0.1:{{service.ports.db}}`). Covers top-level and choice-gated
237    // ports alike, since `resolved_ports` already includes both. Plural
238    // `service.ports.*` so it doesn't collide with the `service.port` leaf
239    // (the template engine nests dotted keys; a key can't be leaf and parent).
240    for (name, port) in resolved_ports {
241        ctx.insert(format!("service.ports.{name}"), port.to_string());
242    }
243    // The primary port (named "http", else the first) answers at the root
244    // URL — for it, `port_url` is exactly `external_url` outside Tailscale.
245    let primary = ports
246        .iter()
247        .copied()
248        .find(|p| p.name.eq_ignore_ascii_case("http"))
249        .or_else(|| ports.first().copied())
250        .map(|p| p.name.clone());
251    let parsed = url.and_then(|u| url::Url::parse(u).ok());
252    let host = parsed
253        .as_ref()
254        .and_then(|u| u.host_str())
255        .map(str::to_string);
256    let scheme = parsed.as_ref().map(|u| u.scheme().to_string());
257    let is_ts = host.as_deref().is_some_and(|h| h.ends_with(".ts.net"));
258    let external_url = ctx.get("service.external_url").cloned();
259
260    for p in ports.iter().copied() {
261        let host_port = resolved_ports
262            .iter()
263            .find(|(n, _)| n == &p.name)
264            .map(|(_, hp)| *hp)
265            .or(p.host_port)
266            .unwrap_or(p.container_port);
267        let is_primary = primary.as_deref() == Some(p.name.as_str());
268        let port_url =
269            if let (true, Some(https), Some(h)) = (is_ts, p.tailscale_https, host.as_deref()) {
270                // Tailscale: this port answers at the service hostname on its
271                // HTTPS port (443 is the bare hostname, no explicit port).
272                if https == 443 {
273                    format!("https://{h}")
274                } else {
275                    format!("https://{h}:{https}")
276                }
277            } else if is_primary && let Some(ext) = &external_url {
278                ext.clone()
279            } else if let (Some(s), Some(h)) = (scheme.as_deref(), host.as_deref()) {
280                // Non-primary port under a raw `--url`: directly published at the
281                // same host on its own host port.
282                format!("{s}://{h}:{host_port}")
283            } else {
284                format!("http://127.0.0.1:{host_port}")
285            };
286        ctx.insert(format!("service.port_url.{}", p.name), port_url);
287    }
288}
289
290/// Ordered KEY=VALUE pairs the registry render produces for a service's `.env`,
291/// before merging with any existing file. Order: declared/group/choice vars,
292/// then ryra's own `SERVICE_*` lines, then CA-trust/extra vars, then any
293/// operator keys the user supplied this run (`--env-file`) that the registry
294/// doesn't declare — so a bring-your-own key isn't silently dropped.
295fn build_env_pairs(
296    home_dir: &std::path::Path,
297    rendered_env: &[EnvVar],
298    resolved_ports: &[(String, u16)],
299    extra_env: &BTreeMap<String, String>,
300    env_overrides: &BTreeMap<String, String>,
301) -> Vec<(String, String)> {
302    let mut pairs: Vec<(String, String)> = Vec::new();
303
304    for env in rendered_env {
305        // Raw KEY=VALUE for podman --env-file. Podman does NOT strip quotes
306        // (single or double), so any shell-style quoting ends up as literal
307        // characters in the container. Tests that source the .env must stick
308        // to values that survive unquoted bash parsing.
309        pairs.push((env.name.clone(), env.value.clone()));
310    }
311
312    // Expose service home path so scripts can reference it.
313    pairs.push(("SERVICE_HOME".to_string(), home_dir.display().to_string()));
314
315    // Expose each [[ports]] entry as SERVICE_PORT_<NAME> with its resolved
316    // host port. The SERVICE_ prefix matches SERVICE_HOME and makes
317    // ryra-emitted vars visually distinct from service-specific ones (which
318    // carry their own naming, e.g. POSTGRES_PASSWORD).
319    for (name, port) in resolved_ports {
320        pairs.push((
321            format!("SERVICE_PORT_{}", name.to_uppercase()),
322            port.to_string(),
323        ));
324    }
325
326    // Extra vars (e.g. CA-cert trust for OIDC).
327    for (key, value) in extra_env {
328        pairs.push((key.clone(), value.clone()));
329    }
330
331    // Operator keys the user passed this run (`--env-file`) that none of the
332    // above emitted — undeclared, but explicitly provided, so write them
333    // rather than drop them. (Process-env overrides only ever carry declared
334    // keys, which are already covered above.)
335    let emitted: BTreeSet<String> = pairs.iter().map(|(k, _)| k.clone()).collect();
336    for (key, value) in env_overrides {
337        if !emitted.contains(key.as_str()) {
338            pairs.push((key.clone(), value.clone()));
339        }
340    }
341
342    pairs
343}
344
345// --- Shared helpers ---
346
347fn render_env_vars(
348    service_def: &ServiceDef,
349    ctx: &BTreeMap<String, String>,
350    env_overrides: &BTreeMap<String, String>,
351    auth_kind: Option<&AuthKind>,
352    enabled_groups: &BTreeSet<String>,
353    selected_choices: &BTreeMap<String, String>,
354    allow_unset_required: bool,
355) -> Result<Vec<EnvVar>> {
356    let mut rendered: Vec<EnvVar> = service_def
357        .env
358        .iter()
359        .map(|env| render_one(env, env_overrides, ctx, None, allow_unset_required))
360        .collect::<Result<Vec<_>>>()?;
361
362    // Append members of every enabled `[[env_group]]`. Groups not toggled
363    // on are fully omitted, no partial state possible.
364    for group in &service_def.env_groups {
365        if !enabled_groups.contains(&group.name) {
366            continue;
367        }
368        let loc = format!("group '{}'", group.name);
369        for env in &group.env {
370            rendered.push(render_one(
371                env,
372                env_overrides,
373                ctx,
374                Some(&loc),
375                allow_unset_required,
376            )?);
377        }
378    }
379
380    // Append the selected option of every `[[choice]]`. With no recorded
381    // selection (a choice added after install) we fall back to the choice's
382    // `default`, which validate() guarantees names a real option. Only the
383    // selected option's members are written; the rest never appear.
384    for choice in &service_def.choices {
385        let selected = selected_choices
386            .get(&choice.name)
387            .unwrap_or(&choice.default);
388        let Some(option) = choice.options.iter().find(|o| &o.name == selected) else {
389            continue;
390        };
391        let loc = format!("choice '{}' option '{}'", choice.name, option.name);
392        for env in &option.env {
393            rendered.push(render_one(
394                env,
395                env_overrides,
396                ctx,
397                Some(&loc),
398                allow_unset_required,
399            )?);
400        }
401    }
402
403    if service_def.integrations.smtp && ctx.contains_key("smtp.host") {
404        for (env_name, value_template) in &service_def.mappings.smtp {
405            let value = template::render(value_template, ctx)?;
406            // Empty values are valid — e.g., inbucket doesn't need username/password.
407            // Static values (no template) are always included as-is.
408            rendered.push(EnvVar {
409                name: env_name.clone(),
410                value,
411                kind: Default::default(),
412                prompt: None,
413                format: Default::default(),
414                length: None,
415                jwt_claims: None,
416                jwt_signing_key: None,
417            });
418        }
419    }
420    if auth_kind.is_some() {
421        for (env_name, value_template) in &service_def.mappings.auth {
422            let value = template::render(value_template, ctx)?;
423            if value.is_empty() {
424                return Err(Error::Template(format!(
425                    "auth mapping {env_name} rendered to empty value from template: {value_template}"
426                )));
427            }
428            rendered.push(EnvVar {
429                name: env_name.clone(),
430                value,
431                kind: Default::default(),
432                prompt: None,
433                format: Default::default(),
434                length: None,
435                jwt_claims: None,
436                jwt_signing_key: None,
437            });
438        }
439    }
440
441    Ok(rendered)
442}
443
444/// Render a single `EnvVar` — apply an override if present, otherwise run
445/// the template. Required group members without an override are a hard
446/// error so the service never starts with half of a group configured —
447/// unless `allow_unset_required` (skip-setup), where they render empty for
448/// the operator to fill in afterwards.
449fn render_one(
450    env: &EnvVar,
451    env_overrides: &BTreeMap<String, String>,
452    ctx: &BTreeMap<String, String>,
453    // Location phrase for the "required member has no value" error, e.g.
454    // `"group 'stripe'"` or `"choice 'billing' option 'live'"`. `None` for a
455    // top-level var (a required top-level var is caught earlier at prompt time).
456    member_of: Option<&str>,
457    allow_unset_required: bool,
458) -> Result<EnvVar> {
459    let value = match env_overrides.get(&env.name) {
460        Some(override_value) => override_value.clone(),
461        None => {
462            if let Some(loc) = member_of
463                && env.kind == EnvKind::Required
464                && !allow_unset_required
465            {
466                return Err(Error::Template(format!(
467                    "required env var '{}' in {loc} has no value; provide it via the interactive prompt or process env (or `--no-setup` to install and fill it in later)",
468                    env.name
469                )));
470            }
471            template::render(&env.value, ctx)?
472        }
473    };
474    Ok(EnvVar {
475        name: env.name.clone(),
476        value,
477        kind: Default::default(),
478        prompt: None,
479        format: Default::default(),
480        length: None,
481        jwt_claims: None,
482        jwt_signing_key: None,
483    })
484}
485
486pub fn extract_secret_refs(value: &str) -> Vec<String> {
487    let mut secrets = Vec::new();
488    let mut rest = value;
489    while let Some(start) = rest.find("{{secret.") {
490        let after = &rest[start + 9..];
491        if let Some(end) = after.find("}}") {
492            secrets.push(after[..end].to_string());
493            rest = &after[end + 2..];
494        } else {
495            break;
496        }
497    }
498    secrets
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504    use crate::config::schema::Config;
505    use crate::registry::service_def::{
506        EnvGroup, EnvKind, EnvVar, PortDef, ServiceDef, ServiceMeta,
507    };
508
509    fn minimal_service_def() -> ServiceDef {
510        ServiceDef {
511            service: ServiceMeta {
512                name: "demo".into(),
513                description: "demo".into(),
514                url: None,
515                kind: Default::default(),
516                architecture: vec![],
517                https: Default::default(),
518                runtime: Default::default(),
519                run: None,
520                build: None,
521                post_install: None,
522                deploy: Default::default(),
523                health_check: None,
524                health_timeout: None,
525            },
526            requirements: None,
527            ports: vec![PortDef {
528                name: "http".into(),
529                container_port: 80,
530                host_port: None,
531                protocol: Default::default(),
532                tailscale_https: None,
533            }],
534            env: vec![
535                EnvVar {
536                    name: "HOSTPORT".into(),
537                    value: "{{service.port}}".into(),
538                    kind: EnvKind::Default,
539                    prompt: None,
540                    format: Default::default(),
541                    length: None,
542                    jwt_claims: None,
543                    jwt_signing_key: None,
544                },
545                EnvVar {
546                    name: "ADMIN_PASSWORD".into(),
547                    value: "{{secret.admin}}".into(),
548                    kind: EnvKind::Default,
549                    prompt: None,
550                    format: Default::default(),
551                    length: Some(16),
552                    jwt_claims: None,
553                    jwt_signing_key: None,
554                },
555            ],
556            env_groups: vec![],
557            choices: vec![],
558            requires: vec![],
559            mappings: Default::default(),
560            integrations: Default::default(),
561            capabilities: Default::default(),
562            backup: None,
563            metrics: None,
564        }
565    }
566
567    fn plain_env(name: &str, value: &str, kind: EnvKind) -> EnvVar {
568        EnvVar {
569            name: name.into(),
570            value: value.into(),
571            kind,
572            prompt: None,
573            format: Default::default(),
574            length: None,
575            jwt_claims: None,
576            jwt_signing_key: None,
577        }
578    }
579
580    fn def_with_oauth_group() -> ServiceDef {
581        let mut def = minimal_service_def();
582        def.env_groups.push(EnvGroup {
583            name: "google_oauth".into(),
584            prompt: "Enable Google?".into(),
585            env: vec![
586                plain_env("CLIENT_ID", "", EnvKind::Required),
587                plain_env("CLIENT_SECRET", "", EnvKind::Required),
588                plain_env("CALLBACK_URL", "https://demo/cb", EnvKind::Default),
589                plain_env("OAUTH_ENABLED", "true", EnvKind::Default),
590            ],
591        });
592        def
593    }
594
595    /// Two-endpoint service like ente: museum API ("http") served over
596    /// Tailscale on :8080, Photos UI ("photos") at the bare hostname (:443).
597    fn multiport_def() -> ServiceDef {
598        let mut def = minimal_service_def();
599        def.ports = vec![
600            PortDef {
601                name: "http".into(),
602                container_port: 8080,
603                host_port: None,
604                protocol: Default::default(),
605                tailscale_https: Some(8080),
606            },
607            PortDef {
608                name: "photos".into(),
609                container_port: 3000,
610                host_port: None,
611                protocol: Default::default(),
612                tailscale_https: Some(443),
613            },
614        ];
615        def
616    }
617
618    fn port_urls(url: Option<&str>, external_url: &str) -> BTreeMap<String, String> {
619        let def = multiport_def();
620        let resolved = vec![
621            ("http".to_string(), 8080u16),
622            ("photos".to_string(), 10002u16),
623        ];
624        let mut ctx = BTreeMap::new();
625        ctx.insert("service.external_url".to_string(), external_url.to_string());
626        let ports: Vec<&PortDef> = def.ports.iter().collect();
627        insert_port_urls(&mut ctx, &ports, &resolved, url);
628        ctx
629    }
630
631    #[test]
632    fn merge_fresh_install_writes_generated_verbatim() {
633        let generated = vec![
634            ("A".to_string(), "1".to_string()),
635            ("B".to_string(), "2".to_string()),
636        ];
637        // No existing file → full overwrite (the original fresh-install behaviour).
638        assert_eq!(
639            merge_env_file(None, &generated, &BTreeSet::new()),
640            "A=1\nB=2\n"
641        );
642    }
643
644    #[test]
645    fn merge_preserves_operator_keys_comments_and_untouched_values() {
646        // A re-add over a file carrying an operator key, a comment, and a
647        // value the user customised — none declared-explicit this run.
648        let existing = "# operator notes\nRYRA_TOKEN=secret-abc\nSITE_TITLE=Custom\n";
649        let generated = vec![
650            ("SITE_TITLE".to_string(), "Default".to_string()),
651            ("ADMIN_EMAIL".to_string(), String::new()),
652            ("SERVICE_HOME".to_string(), "/home/x".to_string()),
653        ];
654        let merged = merge_env_file(Some(existing), &generated, &BTreeSet::new());
655        // Comment + undeclared operator key survive verbatim (the Bug A fix).
656        assert!(merged.contains("# operator notes"));
657        assert!(merged.contains("RYRA_TOKEN=secret-abc"));
658        // A non-explicit existing value is kept, not reset to the default.
659        assert!(merged.contains("SITE_TITLE=Custom"));
660        assert!(!merged.contains("SITE_TITLE=Default"));
661        // New declared keys are appended.
662        assert!(merged.contains("ADMIN_EMAIL="));
663        assert!(merged.contains("SERVICE_HOME=/home/x"));
664    }
665
666    #[test]
667    fn merge_updates_only_explicitly_set_keys() {
668        let existing = "SITE_TITLE=Old\nKEEP=stays\n";
669        let generated = vec![
670            ("SITE_TITLE".to_string(), "New".to_string()),
671            ("KEEP".to_string(), "regenerated".to_string()),
672        ];
673        let explicit = BTreeSet::from(["SITE_TITLE"]);
674        let merged = merge_env_file(Some(existing), &generated, &explicit);
675        assert!(merged.contains("SITE_TITLE=New")); // explicit → updated in place
676        assert!(merged.contains("KEEP=stays")); // untouched → preserved
677        assert!(!merged.contains("KEEP=regenerated"));
678    }
679
680    #[test]
681    fn port_url_loopback_uses_host_ports() {
682        // No --url: primary == external_url (localhost), others at 127.0.0.1:<port>.
683        let ctx = port_urls(None, "http://127.0.0.1:8080");
684        assert_eq!(ctx["service.port_url.http"], "http://127.0.0.1:8080");
685        assert_eq!(ctx["service.port_url.photos"], "http://127.0.0.1:10002");
686    }
687
688    #[test]
689    fn port_url_raw_ip_url_exposes_each_port() {
690        // Raw --url at a tailnet IP: museum == the url, photos directly published.
691        let ctx = port_urls(Some("http://100.69.58.21:8080"), "http://100.69.58.21:8080");
692        assert_eq!(ctx["service.port_url.http"], "http://100.69.58.21:8080");
693        assert_eq!(ctx["service.port_url.photos"], "http://100.69.58.21:10002");
694    }
695
696    #[test]
697    fn port_url_tailscale_splits_root_and_api() {
698        // --tailscale: photos answers at the bare hostname, museum on :8080.
699        let url = "https://ente-debian.cobbler-tuna.ts.net";
700        let ctx = port_urls(Some(url), url);
701        assert_eq!(
702            ctx["service.port_url.http"],
703            "https://ente-debian.cobbler-tuna.ts.net:8080"
704        );
705        assert_eq!(
706            ctx["service.port_url.photos"],
707            "https://ente-debian.cobbler-tuna.ts.net"
708        );
709    }
710
711    fn gen_with_group(
712        def: &ServiceDef,
713        enabled_groups: &BTreeSet<String>,
714        overrides: &BTreeMap<String, String>,
715    ) -> Result<String> {
716        let config = Config::default();
717        let resolved = vec![("http".to_string(), 10002u16)];
718        let output = generate_env(GenerateEnvParams {
719            config: &config,
720            service_def: def,
721            auth_kind: None,
722            host_port: Some(10002),
723            resolved_ports: &resolved,
724            env_overrides: overrides,
725            exposure: &Exposure::Loopback,
726            extra_env: BTreeMap::new(),
727            pre_built_ctx: None,
728            enable_smtp: false,
729            enabled_groups,
730            selected_choices: &BTreeMap::new(),
731            existing_env_file: None,
732            allow_unset_required: false,
733        })?;
734        Ok(output.env_file.content)
735    }
736
737    fn gen_with_choices(
738        def: &ServiceDef,
739        selected: &BTreeMap<String, String>,
740        overrides: &BTreeMap<String, String>,
741    ) -> Result<String> {
742        let config = Config::default();
743        let resolved = vec![("http".to_string(), 10002u16)];
744        let output = generate_env(GenerateEnvParams {
745            config: &config,
746            service_def: def,
747            auth_kind: None,
748            host_port: Some(10002),
749            resolved_ports: &resolved,
750            env_overrides: overrides,
751            exposure: &Exposure::Loopback,
752            extra_env: BTreeMap::new(),
753            pre_built_ctx: None,
754            enable_smtp: false,
755            enabled_groups: &BTreeSet::new(),
756            selected_choices: selected,
757            existing_env_file: None,
758            allow_unset_required: false,
759        })?;
760        Ok(output.env_file.content)
761    }
762
763    fn def_with_billing_choice() -> ServiceDef {
764        toml::from_str(
765            r#"
766[service]
767name = "billed"
768description = "x"
769
770[[ports]]
771name = "http"
772container_port = 8080
773
774[[choice]]
775name = "billing"
776prompt = "Billing mode"
777default = "mock"
778
779[[choice.option]]
780name = "live"
781[[choice.option.env]]
782name = "BILLING_MODE"
783value = "live"
784[[choice.option.env]]
785name = "STRIPE_SECRET_KEY"
786value = ""
787kind = "required"
788
789[[choice.option]]
790name = "mock"
791[[choice.option.env]]
792name = "BILLING_MODE"
793value = "mock"
794"#,
795        )
796        .expect("parse")
797    }
798
799    #[test]
800    fn choice_writes_only_selected_option_members() {
801        let def = def_with_billing_choice();
802        let mut selected = BTreeMap::new();
803        selected.insert("billing".to_string(), "mock".to_string());
804        let content =
805            gen_with_choices(&def, &selected, &BTreeMap::new()).expect("mock selection renders");
806        assert!(content.contains("BILLING_MODE=mock"), "got: {content}");
807        // The `live`-only Stripe var must not appear.
808        assert!(!content.contains("STRIPE_SECRET_KEY"), "got: {content}");
809    }
810
811    #[test]
812    fn choice_option_secret_is_generated() {
813        // Regression: a `{{secret.*}}` referenced only inside a choice option
814        // must still be minted. Secret generation used to scan top-level env
815        // only, so this rendered to an undefined value and `ryra add` failed.
816        let def = toml::from_str::<ServiceDef>(
817            r#"
818[service]
819name = "s"
820description = "x"
821[[ports]]
822name = "http"
823container_port = 8080
824[[choice]]
825name = "database"
826prompt = "Database"
827default = "internal"
828[[choice.option]]
829name = "internal"
830[[choice.option.env]]
831name = "DB_PASSWORD"
832value = "{{secret.db_password}}"
833[[choice.option]]
834name = "external"
835[[choice.option.env]]
836name = "DB_PASSWORD"
837value = ""
838kind = "required"
839"#,
840        )
841        .expect("parse");
842        let mut selected = BTreeMap::new();
843        selected.insert("database".to_string(), "internal".to_string());
844        let content = gen_with_choices(&def, &selected, &BTreeMap::new())
845            .expect("renders with generated secret");
846        let line = content
847            .lines()
848            .find(|l| l.starts_with("DB_PASSWORD="))
849            .expect("DB_PASSWORD present");
850        let val = line.trim_start_matches("DB_PASSWORD=");
851        assert!(!val.is_empty() && !val.contains("{{"), "got: {line}");
852    }
853
854    #[test]
855    fn choice_falls_back_to_default_when_unselected() {
856        let def = def_with_billing_choice();
857        // Empty selection map -> the `default` (mock) is rendered.
858        let content = gen_with_choices(&def, &BTreeMap::new(), &BTreeMap::new())
859            .expect("default selection renders");
860        assert!(content.contains("BILLING_MODE=mock"), "got: {content}");
861    }
862
863    #[test]
864    fn choice_required_member_needs_a_value() {
865        // Selecting `live` without providing STRIPE_SECRET_KEY must error,
866        // mirroring a required group member with no value.
867        let def = def_with_billing_choice();
868        let mut selected = BTreeMap::new();
869        selected.insert("billing".to_string(), "live".to_string());
870        let err = gen_with_choices(&def, &selected, &BTreeMap::new())
871            .expect_err("required member without value must fail");
872        assert!(
873            format!("{err}").contains("STRIPE_SECRET_KEY"),
874            "error names the missing var: {err}"
875        );
876    }
877
878    #[test]
879    fn choice_required_member_value_is_written() {
880        let def = def_with_billing_choice();
881        let mut selected = BTreeMap::new();
882        selected.insert("billing".to_string(), "live".to_string());
883        let mut overrides = BTreeMap::new();
884        overrides.insert("STRIPE_SECRET_KEY".to_string(), "sk_test_123".to_string());
885        let content = gen_with_choices(&def, &selected, &overrides).expect("live renders");
886        assert!(content.contains("BILLING_MODE=live"), "got: {content}");
887        assert!(
888            content.contains("STRIPE_SECRET_KEY=sk_test_123"),
889            "got: {content}"
890        );
891    }
892
893    #[test]
894    fn env_group_disabled_writes_no_members() {
895        let def = def_with_oauth_group();
896        let no_groups = BTreeSet::new();
897        let content = gen_with_group(&def, &no_groups, &BTreeMap::new())
898            .expect("generate_env should succeed with no groups enabled");
899        for name in [
900            "CLIENT_ID",
901            "CLIENT_SECRET",
902            "CALLBACK_URL",
903            "OAUTH_ENABLED",
904        ] {
905            assert!(
906                !content.contains(&format!("{name}=")),
907                "disabled group member '{name}' leaked into .env: {content}"
908            );
909        }
910    }
911
912    #[test]
913    fn env_group_enabled_writes_all_members() {
914        let def = def_with_oauth_group();
915        let mut enabled = BTreeSet::new();
916        enabled.insert("google_oauth".to_string());
917        let mut overrides = BTreeMap::new();
918        overrides.insert("CLIENT_ID".into(), "my-client".into());
919        overrides.insert("CLIENT_SECRET".into(), "my-secret".into());
920        let content = gen_with_group(&def, &enabled, &overrides)
921            .expect("generate_env should succeed with the group enabled + overrides supplied");
922        assert!(content.contains("CLIENT_ID=my-client"), "{content}");
923        assert!(content.contains("CLIENT_SECRET=my-secret"), "{content}");
924        assert!(
925            content.contains("CALLBACK_URL=https://demo/cb"),
926            "{content}"
927        );
928        assert!(content.contains("OAUTH_ENABLED=true"), "{content}");
929    }
930
931    #[test]
932    fn env_group_enabled_required_member_without_override_errors() {
933        let def = def_with_oauth_group();
934        let mut enabled = BTreeSet::new();
935        enabled.insert("google_oauth".to_string());
936        // Intentionally leave CLIENT_SECRET out — required members with no
937        // value must fail loudly, never produce an empty .env entry.
938        let mut overrides = BTreeMap::new();
939        overrides.insert("CLIENT_ID".into(), "my-client".into());
940        let err = gen_with_group(&def, &enabled, &overrides)
941            .expect_err("required member missing must surface as an error");
942        let msg = err.to_string();
943        assert!(
944            msg.contains("CLIENT_SECRET") && msg.contains("google_oauth"),
945            "error should name the missing member + group: {msg}"
946        );
947    }
948
949    /// Regression: when the interactive CLI builds `pre_built_ctx` with
950    /// `host_port: None` (the real port isn't allocated yet), `generate_env`
951    /// must still produce a valid env file with the real `service.port`.
952    /// Previously the pre-built ctx was reused wholesale, so any env value
953    /// referencing `{{service.port}}` failed strict-mode rendering with
954    /// "undefined value".
955    #[test]
956    fn generate_env_rebuilds_port_when_prebuilt_ctx_lacks_it() {
957        let def = minimal_service_def();
958        let config = Config::default();
959        // Build the pre-built ctx as the interactive prompt phase does:
960        // host_port is None, so `service.port` is absent from the ctx.
961        let prebuilt =
962            context::build_context(&config, &def, None, None, &Exposure::Loopback, false)
963                .expect("build_context with host_port=None should succeed");
964        assert!(!prebuilt.contains_key("service.port"));
965        let admin_secret = prebuilt
966            .get("secret.admin")
967            .expect("secret.admin should have been generated in the prompt phase")
968            .clone();
969
970        // Now run generate_env with the real allocated host_port.
971        let resolved = vec![("http".to_string(), 10002u16)];
972        let no_groups = BTreeSet::new();
973        let output = generate_env(GenerateEnvParams {
974            config: &config,
975            service_def: &def,
976            auth_kind: None,
977            host_port: Some(10002),
978            resolved_ports: &resolved,
979            env_overrides: &BTreeMap::new(),
980            exposure: &Exposure::Loopback,
981            extra_env: BTreeMap::new(),
982            pre_built_ctx: Some(prebuilt),
983            enable_smtp: false,
984            enabled_groups: &no_groups,
985            selected_choices: &BTreeMap::new(),
986            existing_env_file: None,
987            allow_unset_required: false,
988        })
989        .expect("generate_env must succeed with the real host_port");
990
991        // The resulting .env must carry the allocated port, and the randomly
992        // generated secret from the prompt phase must be preserved verbatim.
993        assert!(
994            output.env_file.content.contains("HOSTPORT=10002"),
995            ".env missing real port: {}",
996            output.env_file.content,
997        );
998        assert!(
999            output
1000                .env_file
1001                .content
1002                .contains(&format!("ADMIN_PASSWORD={admin_secret}")),
1003            "prompt-phase secret not preserved in .env: {}",
1004            output.env_file.content,
1005        );
1006    }
1007}