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::registry::service_def::{AuthKind, EnvKind, EnvVar, ServiceDef};
10
11#[derive(Debug)]
12pub struct GeneratedFile {
13    pub path: PathBuf,
14    pub content: String,
15}
16
17/// Parameters for [`generate_env`].
18pub struct GenerateEnvParams<'a> {
19    pub config: &'a Config,
20    pub service_def: &'a ServiceDef,
21    /// The auth kind the user chose to enable, if any.
22    pub auth_kind: Option<&'a AuthKind>,
23    /// Primary host port (for `service.url` / `service.port` templating).
24    pub host_port: Option<u16>,
25    /// Per-port resolved host ports, keyed by port name (e.g. "http", "smtp").
26    /// Used to emit `PORT_*` lines in the .env file — each entry here
27    /// corresponds to one `[[ports]]` definition in service.toml.
28    pub resolved_ports: &'a [(String, u16)],
29    pub env_overrides: &'a BTreeMap<String, String>,
30    /// Public URL for the service (used in templates as `{{service.external_url}}`).
31    pub url: Option<&'a str>,
32    /// Additional env vars to append to the .env file (e.g., CA cert trust vars).
33    pub extra_env: BTreeMap<String, String>,
34    /// Pre-built template context. When provided, secrets and auth credentials
35    /// from this context are reused instead of generating fresh ones. This
36    /// ensures the values shown during interactive prompts match what gets
37    /// written to the .env file.
38    pub pre_built_ctx: Option<BTreeMap<String, String>>,
39    /// Whether this service should use the globally configured SMTP. When
40    /// false, smtp.* is left out of the context and [mappings.smtp] is skipped
41    /// — lets the user opt a single service out of email without clearing the
42    /// global SMTP config.
43    pub enable_smtp: bool,
44    /// Names of `[[env_group]]` entries the user toggled on. Members of
45    /// groups not listed here are fully omitted from the generated `.env`.
46    pub enabled_groups: &'a BTreeSet<String>,
47}
48
49/// Result of generating env for a service.
50pub struct EnvOutput {
51    pub env_file: GeneratedFile,
52    /// The template context used during generation (for auth registration, etc.).
53    pub ctx: BTreeMap<String, String>,
54}
55
56/// Generate the .env file for a service based on its definition and context.
57pub fn generate_env(params: GenerateEnvParams<'_>) -> Result<EnvOutput> {
58    let name = &params.service_def.service.name;
59
60    // Always build a fresh context with the now-known host_port. Overlay
61    // secret.* and auth.* entries from the pre-built context so randomly
62    // generated values the user saw during prompts stay stable.
63    let mut ctx = context::build_context(
64        params.config,
65        params.service_def,
66        params.host_port,
67        params.auth_kind,
68        params.url,
69        params.enable_smtp,
70    )?;
71    if let Some(prebuilt) = params.pre_built_ctx {
72        for (key, value) in prebuilt {
73            if key.starts_with("secret.") || key.starts_with("auth.") {
74                ctx.insert(key, value);
75            }
76        }
77    }
78    let rendered_env = render_env_vars(
79        params.service_def,
80        &ctx,
81        params.env_overrides,
82        params.auth_kind,
83        params.enabled_groups,
84    )?;
85
86    // Build .env file content
87    let home_dir = crate::service_home(name)?;
88    let mut env_file = build_env_file(&home_dir, &rendered_env, params.resolved_ports);
89
90    // Append extra env vars (e.g., CA cert trust for OIDC)
91    for (key, value) in &params.extra_env {
92        env_file.content.push_str(&format!("{key}={value}\n"));
93    }
94
95    Ok(EnvOutput { env_file, ctx })
96}
97
98/// Build the .env file for a service.
99fn build_env_file(
100    home_dir: &std::path::Path,
101    rendered_env: &[EnvVar],
102    resolved_ports: &[(String, u16)],
103) -> GeneratedFile {
104    let mut lines = Vec::new();
105
106    for env in rendered_env {
107        // Write raw KEY=VALUE for podman --env-file. Podman does NOT strip
108        // quotes (single or double), so any shell-style quoting ends up as
109        // literal characters in the container. Tests that source the .env
110        // must stick to values that survive unquoted bash parsing.
111        lines.push(format!("{}={}", env.name, env.value));
112    }
113
114    // Expose service home path so scripts can reference it
115    lines.push(format!("SERVICE_HOME={}", home_dir.display()));
116
117    // Expose each [[ports]] entry as SERVICE_PORT_<NAME> with its
118    // resolved host port. The SERVICE_ prefix matches SERVICE_HOME and
119    // makes ryra-emitted vars visually distinct from service-specific
120    // ones (which carry their own naming, e.g. POSTGRES_PASSWORD).
121    for (name, port) in resolved_ports {
122        let var_name = format!("SERVICE_PORT_{}", name.to_uppercase());
123        lines.push(format!("{var_name}={port}"));
124    }
125
126    GeneratedFile {
127        path: home_dir.join(".env"),
128        content: lines.join("\n") + "\n",
129    }
130}
131
132// --- Shared helpers ---
133
134fn render_env_vars(
135    service_def: &ServiceDef,
136    ctx: &BTreeMap<String, String>,
137    env_overrides: &BTreeMap<String, String>,
138    auth_kind: Option<&AuthKind>,
139    enabled_groups: &BTreeSet<String>,
140) -> Result<Vec<EnvVar>> {
141    let mut rendered: Vec<EnvVar> = service_def
142        .env
143        .iter()
144        .map(|env| render_one(env, env_overrides, ctx, None))
145        .collect::<Result<Vec<_>>>()?;
146
147    // Append members of every enabled `[[env_group]]`. Groups not toggled
148    // on are fully omitted — no partial state possible.
149    for group in &service_def.env_groups {
150        if !enabled_groups.contains(&group.name) {
151            continue;
152        }
153        for env in &group.env {
154            rendered.push(render_one(env, env_overrides, ctx, Some(&group.name))?);
155        }
156    }
157
158    if service_def.integrations.smtp && ctx.contains_key("smtp.host") {
159        for (env_name, value_template) in &service_def.mappings.smtp {
160            let value = template::render(value_template, ctx)?;
161            // Empty values are valid — e.g., inbucket doesn't need username/password.
162            // Static values (no template) are always included as-is.
163            rendered.push(EnvVar {
164                name: env_name.clone(),
165                value,
166                kind: Default::default(),
167                prompt: None,
168                format: Default::default(),
169                length: None,
170                jwt_claims: None,
171                jwt_signing_key: None,
172            });
173        }
174    }
175    if auth_kind.is_some() {
176        for (env_name, value_template) in &service_def.mappings.auth {
177            let value = template::render(value_template, ctx)?;
178            if value.is_empty() {
179                return Err(Error::Template(format!(
180                    "auth mapping {env_name} rendered to empty value from template: {value_template}"
181                )));
182            }
183            rendered.push(EnvVar {
184                name: env_name.clone(),
185                value,
186                kind: Default::default(),
187                prompt: None,
188                format: Default::default(),
189                length: None,
190                jwt_claims: None,
191                jwt_signing_key: None,
192            });
193        }
194    }
195
196    Ok(rendered)
197}
198
199/// Render a single `EnvVar` — apply an override if present, otherwise run
200/// the template. Required group members without an override are a hard
201/// error so the service never starts with half of a group configured.
202fn render_one(
203    env: &EnvVar,
204    env_overrides: &BTreeMap<String, String>,
205    ctx: &BTreeMap<String, String>,
206    group: Option<&str>,
207) -> Result<EnvVar> {
208    let value = match env_overrides.get(&env.name) {
209        Some(override_value) => override_value.clone(),
210        None => {
211            if let Some(group_name) = group
212                && env.kind == EnvKind::Required
213            {
214                return Err(Error::Template(format!(
215                    "required env var '{}' in group '{}' has no value — provide it via the interactive prompt or process env",
216                    env.name, group_name
217                )));
218            }
219            template::render(&env.value, ctx)?
220        }
221    };
222    Ok(EnvVar {
223        name: env.name.clone(),
224        value,
225        kind: Default::default(),
226        prompt: None,
227        format: Default::default(),
228        length: None,
229        jwt_claims: None,
230        jwt_signing_key: None,
231    })
232}
233
234pub fn extract_secret_refs(value: &str) -> Vec<String> {
235    let mut secrets = Vec::new();
236    let mut rest = value;
237    while let Some(start) = rest.find("{{secret.") {
238        let after = &rest[start + 9..];
239        if let Some(end) = after.find("}}") {
240            secrets.push(after[..end].to_string());
241            rest = &after[end + 2..];
242        } else {
243            break;
244        }
245    }
246    secrets
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use crate::config::schema::Config;
253    use crate::registry::service_def::{
254        EnvGroup, EnvKind, EnvVar, PortDef, ServiceDef, ServiceMeta,
255    };
256
257    fn minimal_service_def() -> ServiceDef {
258        ServiceDef {
259            service: ServiceMeta {
260                name: "demo".into(),
261                description: "demo".into(),
262                url: None,
263                kind: Default::default(),
264                architecture: vec![],
265                https: Default::default(),
266            },
267            requirements: None,
268            ports: vec![PortDef {
269                name: "http".into(),
270                container_port: 80,
271                host_port: None,
272                protocol: Default::default(),
273            }],
274            env: vec![
275                EnvVar {
276                    name: "HOSTPORT".into(),
277                    value: "{{service.port}}".into(),
278                    kind: EnvKind::Default,
279                    prompt: None,
280                    format: Default::default(),
281                    length: None,
282                    jwt_claims: None,
283                    jwt_signing_key: None,
284                },
285                EnvVar {
286                    name: "ADMIN_PASSWORD".into(),
287                    value: "{{secret.admin}}".into(),
288                    kind: EnvKind::Default,
289                    prompt: None,
290                    format: Default::default(),
291                    length: Some(16),
292                    jwt_claims: None,
293                    jwt_signing_key: None,
294                },
295            ],
296            env_groups: vec![],
297            requires: vec![],
298            mappings: Default::default(),
299            integrations: Default::default(),
300            capabilities: Default::default(),
301            backup: None,
302        }
303    }
304
305    fn plain_env(name: &str, value: &str, kind: EnvKind) -> EnvVar {
306        EnvVar {
307            name: name.into(),
308            value: value.into(),
309            kind,
310            prompt: None,
311            format: Default::default(),
312            length: None,
313            jwt_claims: None,
314            jwt_signing_key: None,
315        }
316    }
317
318    fn def_with_oauth_group() -> ServiceDef {
319        let mut def = minimal_service_def();
320        def.env_groups.push(EnvGroup {
321            name: "google_oauth".into(),
322            prompt: "Enable Google?".into(),
323            env: vec![
324                plain_env("CLIENT_ID", "", EnvKind::Required),
325                plain_env("CLIENT_SECRET", "", EnvKind::Required),
326                plain_env("CALLBACK_URL", "https://demo/cb", EnvKind::Default),
327                plain_env("OAUTH_ENABLED", "true", EnvKind::Default),
328            ],
329        });
330        def
331    }
332
333    fn gen_with_group(
334        def: &ServiceDef,
335        enabled_groups: &BTreeSet<String>,
336        overrides: &BTreeMap<String, String>,
337    ) -> Result<String> {
338        let config = Config::default();
339        let resolved = vec![("http".to_string(), 10002u16)];
340        let output = generate_env(GenerateEnvParams {
341            config: &config,
342            service_def: def,
343            auth_kind: None,
344            host_port: Some(10002),
345            resolved_ports: &resolved,
346            env_overrides: overrides,
347            url: None,
348            extra_env: BTreeMap::new(),
349            pre_built_ctx: None,
350            enable_smtp: false,
351            enabled_groups,
352        })?;
353        Ok(output.env_file.content)
354    }
355
356    #[test]
357    fn env_group_disabled_writes_no_members() {
358        let def = def_with_oauth_group();
359        let no_groups = BTreeSet::new();
360        let content = gen_with_group(&def, &no_groups, &BTreeMap::new())
361            .expect("generate_env should succeed with no groups enabled");
362        for name in [
363            "CLIENT_ID",
364            "CLIENT_SECRET",
365            "CALLBACK_URL",
366            "OAUTH_ENABLED",
367        ] {
368            assert!(
369                !content.contains(&format!("{name}=")),
370                "disabled group member '{name}' leaked into .env: {content}"
371            );
372        }
373    }
374
375    #[test]
376    fn env_group_enabled_writes_all_members() {
377        let def = def_with_oauth_group();
378        let mut enabled = BTreeSet::new();
379        enabled.insert("google_oauth".to_string());
380        let mut overrides = BTreeMap::new();
381        overrides.insert("CLIENT_ID".into(), "my-client".into());
382        overrides.insert("CLIENT_SECRET".into(), "my-secret".into());
383        let content = gen_with_group(&def, &enabled, &overrides)
384            .expect("generate_env should succeed with the group enabled + overrides supplied");
385        assert!(content.contains("CLIENT_ID=my-client"), "{content}");
386        assert!(content.contains("CLIENT_SECRET=my-secret"), "{content}");
387        assert!(
388            content.contains("CALLBACK_URL=https://demo/cb"),
389            "{content}"
390        );
391        assert!(content.contains("OAUTH_ENABLED=true"), "{content}");
392    }
393
394    #[test]
395    fn env_group_enabled_required_member_without_override_errors() {
396        let def = def_with_oauth_group();
397        let mut enabled = BTreeSet::new();
398        enabled.insert("google_oauth".to_string());
399        // Intentionally leave CLIENT_SECRET out — required members with no
400        // value must fail loudly, never produce an empty .env entry.
401        let mut overrides = BTreeMap::new();
402        overrides.insert("CLIENT_ID".into(), "my-client".into());
403        let err = gen_with_group(&def, &enabled, &overrides)
404            .expect_err("required member missing must surface as an error");
405        let msg = err.to_string();
406        assert!(
407            msg.contains("CLIENT_SECRET") && msg.contains("google_oauth"),
408            "error should name the missing member + group: {msg}"
409        );
410    }
411
412    /// Regression: when the interactive CLI builds `pre_built_ctx` with
413    /// `host_port: None` (the real port isn't allocated yet), `generate_env`
414    /// must still produce a valid env file with the real `service.port`.
415    /// Previously the pre-built ctx was reused wholesale, so any env value
416    /// referencing `{{service.port}}` failed strict-mode rendering with
417    /// "undefined value".
418    #[test]
419    fn generate_env_rebuilds_port_when_prebuilt_ctx_lacks_it() {
420        let def = minimal_service_def();
421        let config = Config::default();
422        // Build the pre-built ctx as the interactive prompt phase does:
423        // host_port is None, so `service.port` is absent from the ctx.
424        let prebuilt = context::build_context(&config, &def, None, None, None, false)
425            .expect("build_context with host_port=None should succeed");
426        assert!(!prebuilt.contains_key("service.port"));
427        let admin_secret = prebuilt
428            .get("secret.admin")
429            .expect("secret.admin should have been generated in the prompt phase")
430            .clone();
431
432        // Now run generate_env with the real allocated host_port.
433        let resolved = vec![("http".to_string(), 10002u16)];
434        let no_groups = BTreeSet::new();
435        let output = generate_env(GenerateEnvParams {
436            config: &config,
437            service_def: &def,
438            auth_kind: None,
439            host_port: Some(10002),
440            resolved_ports: &resolved,
441            env_overrides: &BTreeMap::new(),
442            url: None,
443            extra_env: BTreeMap::new(),
444            pre_built_ctx: Some(prebuilt),
445            enable_smtp: false,
446            enabled_groups: &no_groups,
447        })
448        .expect("generate_env must succeed with the real host_port");
449
450        // The resulting .env must carry the allocated port, and the randomly
451        // generated secret from the prompt phase must be preserved verbatim.
452        assert!(
453            output.env_file.content.contains("HOSTPORT=10002"),
454            ".env missing real port: {}",
455            output.env_file.content,
456        );
457        assert!(
458            output
459                .env_file
460                .content
461                .contains(&format!("ADMIN_PASSWORD={admin_secret}")),
462            "prompt-phase secret not preserved in .env: {}",
463            output.env_file.content,
464        );
465    }
466}