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
17pub struct GenerateEnvParams<'a> {
19 pub config: &'a Config,
20 pub service_def: &'a ServiceDef,
21 pub auth_kind: Option<&'a AuthKind>,
23 pub host_port: Option<u16>,
25 pub resolved_ports: &'a [(String, u16)],
29 pub env_overrides: &'a BTreeMap<String, String>,
30 pub url: Option<&'a str>,
32 pub extra_env: BTreeMap<String, String>,
34 pub pre_built_ctx: Option<BTreeMap<String, String>>,
39 pub enable_smtp: bool,
44 pub enabled_groups: &'a BTreeSet<String>,
47}
48
49pub struct EnvOutput {
51 pub env_file: GeneratedFile,
52 pub ctx: BTreeMap<String, String>,
54}
55
56pub fn generate_env(params: GenerateEnvParams<'_>) -> Result<EnvOutput> {
58 let name = ¶ms.service_def.service.name;
59
60 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 let home_dir = crate::service_home(name)?;
88 let mut env_file = build_env_file(&home_dir, &rendered_env, params.resolved_ports);
89
90 for (key, value) in ¶ms.extra_env {
92 env_file.content.push_str(&format!("{key}={value}\n"));
93 }
94
95 Ok(EnvOutput { env_file, ctx })
96}
97
98fn 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 lines.push(format!("{}={}", env.name, env.value));
112 }
113
114 lines.push(format!("SERVICE_HOME={}", home_dir.display()));
116
117 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
132fn 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 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 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
199fn 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 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 #[test]
419 fn generate_env_rebuilds_port_when_prebuilt_ctx_lacks_it() {
420 let def = minimal_service_def();
421 let config = Config::default();
422 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 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 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}