1#[cfg(test)]
11use crate::deploy::env_production::parse_env_example_structured;
12use crate::deploy::env_production::EnvLine;
13use crate::deploy::secret_keys::is_secret_key;
14
15const TEMPLATE: &str = include_str!("files/do/app.yaml.tpl");
16
17pub struct AppYamlContext {
19 pub name: String,
21 pub repo: String,
23 pub web_bin: String,
26 pub workers: Vec<String>,
28 pub env_lines: Option<Vec<EnvLine>>,
32 pub preserved_name: Option<String>,
35 pub preserved_region: Option<String>,
38 pub preserved_github_repo: Option<String>,
41 pub preserved_github_branch: Option<String>,
44}
45
46pub fn render_app_yaml(ctx: &AppYamlContext) -> String {
48 let workers_block = render_workers_block(&ctx.workers);
49 let envs_block = match &ctx.env_lines {
50 Some(lines) => render_envs_block_from_lines(lines),
51 None => String::new(),
52 };
53
54 let name = ctx.preserved_name.as_deref().unwrap_or(ctx.name.as_str());
56 let region = ctx.preserved_region.as_deref().unwrap_or("fra1");
57 let repo = ctx
58 .preserved_github_repo
59 .as_deref()
60 .unwrap_or(ctx.repo.as_str());
61 let branch = ctx.preserved_github_branch.as_deref().unwrap_or("main");
62
63 let rendered = TEMPLATE
64 .replace("{{NAME}}", name)
65 .replace("{{REGION}}", region)
66 .replace("{{REPO}}", repo)
67 .replace("{{GITHUB_BRANCH}}", branch)
68 .replace("{{WORKERS_BLOCK}}", &workers_block)
69 .replace("{{ENVS_BLOCK}}", &envs_block);
70 debug_assert!(
71 !rendered.contains("{{"),
72 "unresolved template token in rendered .do/app.yaml"
73 );
74 rendered
75}
76
77#[cfg(test)]
79fn render_envs_block(env_example_contents: &str) -> String {
80 let lines = parse_env_example_structured(env_example_contents);
81 render_envs_block_from_lines(&lines)
82}
83
84fn render_envs_block_from_lines(lines: &[EnvLine]) -> String {
85 let mut out = String::new();
86 let indent = " "; for line in lines {
88 match line {
89 EnvLine::Key(key) => {
90 out.push_str(indent);
91 out.push_str("- key: ");
92 out.push_str(key);
93 out.push('\n');
94 out.push_str(indent);
95 out.push_str(" value: \"\"\n");
96 if is_secret_key(key) {
97 out.push_str(indent);
98 out.push_str(" type: SECRET\n");
99 out.push_str(indent);
100 out.push_str(" scope: RUN_AND_BUILD_TIME\n");
101 } else {
102 out.push_str(indent);
103 out.push_str(" scope: RUN_TIME\n");
104 }
105 }
106 EnvLine::Blank => {
107 out.push('\n');
108 }
109 EnvLine::Comment => {
110 }
113 }
114 }
115 while out.ends_with('\n') {
116 out.pop();
117 }
118 out
119}
120
121fn render_workers_block(workers: &[String]) -> String {
122 if workers.is_empty() {
123 return "\
125# workers: (one entry per non-test/dev/debug [[bin]] other than the service)
126# workers:
127# - name: example-worker
128# dockerfile_path: Dockerfile
129# source_dir: /
130# run_command: /usr/local/bin/example-worker
131# instance_size_slug: apps-s-1vcpu-0.5gb
132# instance_count: 1
133"
134 .to_string();
135 }
136
137 let mut out = String::from(
138 "# workers: (one entry per non-test/dev/debug [[bin]] other than the service)\nworkers:\n",
139 );
140 for name in workers {
141 out.push_str(&format!(
142 " - name: {name}\n dockerfile_path: Dockerfile\n source_dir: /\n run_command: /usr/local/bin/{name}\n instance_size_slug: apps-s-1vcpu-0.5gb\n instance_count: 1\n"
143 ));
144 }
145 out
146}
147
148pub fn sanitize_do_app_name(package_name: &str) -> String {
154 let lowered = package_name.to_lowercase();
155 let mut out = String::with_capacity(lowered.len());
156 for c in lowered.chars() {
157 if c.is_ascii_lowercase() || c.is_ascii_digit() {
158 out.push(c);
159 } else if c == '-' || c == '_' || c == ' ' {
160 out.push('-');
161 }
162 }
164 let mut collapsed = String::with_capacity(out.len());
166 let mut prev_dash = false;
167 for c in out.chars() {
168 if c == '-' {
169 if !prev_dash {
170 collapsed.push(c);
171 }
172 prev_dash = true;
173 } else {
174 collapsed.push(c);
175 prev_dash = false;
176 }
177 }
178 collapsed.trim_matches('-').to_string()
179}
180
181pub fn parse_git_remote(remote_url: &str) -> Option<String> {
183 let url = remote_url.trim();
184 let tail = if let Some(rest) = url.strip_prefix("https://github.com/") {
185 rest
186 } else if let Some(rest) = url.strip_prefix("git@github.com:") {
187 rest
188 } else {
189 return None;
190 };
191 let tail = tail.strip_suffix(".git").unwrap_or(tail);
192 let mut parts = tail.splitn(3, '/');
193 let owner = parts.next()?;
194 let repo = parts.next()?;
195 if owner.is_empty() || repo.is_empty() {
196 return None;
197 }
198 Some(format!("{owner}/{repo}"))
199}
200
201pub fn is_test_like_bin(name: &str) -> bool {
208 const PREFIXES: &[&str] = &["test_", "test-", "dev_", "dev-", "debug_", "debug-"];
209 PREFIXES.iter().any(|p| name.starts_with(p))
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 fn ctx(name: &str, repo: &str, workers: Vec<&str>, envs: Vec<&str>) -> AppYamlContext {
217 AppYamlContext {
218 name: name.to_string(),
219 repo: repo.to_string(),
220 web_bin: name.to_string(),
221 workers: workers.into_iter().map(String::from).collect(),
222 env_lines: Some(
223 envs.into_iter()
224 .map(|k| EnvLine::Key(k.to_string()))
225 .collect(),
226 ),
227 preserved_name: None,
228 preserved_region: None,
229 preserved_github_repo: None,
230 preserved_github_branch: None,
231 }
232 }
233
234 pub(super) fn ctx_without_env(name: &str, repo: &str) -> AppYamlContext {
235 AppYamlContext {
236 name: name.to_string(),
237 repo: repo.to_string(),
238 web_bin: name.to_string(),
239 workers: Vec::new(),
240 env_lines: None,
241 preserved_name: None,
242 preserved_region: None,
243 preserved_github_repo: None,
244 preserved_github_branch: None,
245 }
246 }
247
248 #[test]
249 fn sanitize_simple_passthrough() {
250 assert_eq!(sanitize_do_app_name("gestiscilo"), "gestiscilo");
251 }
252
253 #[test]
254 fn sanitize_lowercases_and_replaces_underscores_and_spaces() {
255 assert_eq!(sanitize_do_app_name("My_Cool App"), "my-cool-app");
256 }
257
258 #[test]
259 fn sanitize_collapses_dashes() {
260 assert_eq!(sanitize_do_app_name("foo__bar"), "foo-bar");
261 assert_eq!(sanitize_do_app_name("foo---bar"), "foo-bar");
262 }
263
264 #[test]
265 fn sanitize_strips_non_alphanum() {
266 assert_eq!(sanitize_do_app_name("X!@#"), "x");
267 }
268
269 #[test]
270 fn parse_git_remote_https_with_dot_git() {
271 assert_eq!(
272 parse_git_remote("https://github.com/owner/repo.git"),
273 Some("owner/repo".to_string())
274 );
275 }
276
277 #[test]
278 fn parse_git_remote_https_no_dot_git() {
279 assert_eq!(
280 parse_git_remote("https://github.com/owner/repo"),
281 Some("owner/repo".to_string())
282 );
283 }
284
285 #[test]
286 fn parse_git_remote_ssh_with_dot_git() {
287 assert_eq!(
288 parse_git_remote("git@github.com:owner/repo.git"),
289 Some("owner/repo".to_string())
290 );
291 }
292
293 #[test]
294 fn parse_git_remote_ssh_no_dot_git() {
295 assert_eq!(
296 parse_git_remote("git@github.com:owner/repo"),
297 Some("owner/repo".to_string())
298 );
299 }
300
301 #[test]
302 fn parse_git_remote_rejects_non_github() {
303 assert_eq!(parse_git_remote("https://gitlab.com/x/y"), None);
304 }
305
306 #[test]
307 fn is_test_like_bin_matches_prefixes() {
308 for n in [
309 "test_foo",
310 "test-foo",
311 "dev_foo",
312 "dev-foo",
313 "debug_foo",
314 "debug-foo",
315 ] {
316 assert!(is_test_like_bin(n), "expected {n} to be test-like");
317 }
318 }
319
320 #[test]
321 fn is_test_like_bin_rejects_normal_names() {
322 for n in ["web", "worker", "screenshot-worker", "api"] {
323 assert!(!is_test_like_bin(n));
324 }
325 }
326
327 #[test]
328 fn render_app_yaml_contains_static_fields() {
329 let c = ctx("myapp", "owner/repo", vec![], vec![]);
330 let out = render_app_yaml(&c);
331 assert!(out.starts_with("# Generated by ferro do:init — edit to your needs"));
332 assert!(out.contains("name: myapp"));
333 assert!(out.contains("region: fra1"));
334 assert!(out.contains("repo: owner/repo"));
335 assert!(out.contains("branch: main"));
336 assert!(out.contains("services:"));
337 assert!(out.contains("name: web"));
338 assert!(out.contains("envs:"));
339 assert!(!out.contains("databases:"));
340 }
341
342 #[test]
343 fn render_app_yaml_with_empty_workers_emits_commented_example() {
344 let c = ctx("a", "o/r", vec![], vec![]);
345 let out = render_app_yaml(&c);
346 assert!(out.contains("# workers:"));
347 assert!(out.contains("# workers: (one entry per"));
348 }
349
350 #[test]
351 fn render_app_yaml_emits_each_worker() {
352 let c = ctx(
353 "a",
354 "o/r",
355 vec!["screenshot-worker", "queue-worker"],
356 vec![],
357 );
358 let out = render_app_yaml(&c);
359 assert!(out.contains("workers:\n"));
360 assert!(out.contains("- name: screenshot-worker"));
361 assert!(out.contains("run_command: /usr/local/bin/screenshot-worker"));
362 assert!(out.contains("- name: queue-worker"));
363 assert!(out.contains("run_command: /usr/local/bin/queue-worker"));
364 }
365
366 #[test]
367 fn render_app_yaml_emits_real_envs_entries() {
368 let c = ctx(
369 "a",
370 "o/r",
371 vec![],
372 vec!["APP_ENV", "APP_URL", "DATABASE_URL"],
373 );
374 let out = render_app_yaml(&c);
375 assert!(out.contains("- key: APP_ENV"));
376 assert!(out.contains("- key: APP_URL"));
377 assert!(out.contains("- key: DATABASE_URL"));
378 assert!(!out.contains("# - APP_ENV"));
379 }
380}
381
382#[cfg(test)]
383mod envs_block_tests {
384 use super::*;
385
386 #[test]
387 fn envs_block_from_env_example() {
388 let src = "DATABASE_URL=\nSTRIPE_SECRET_KEY=\nAPP_NAME=\n";
389 let out = render_envs_block(src);
390 assert!(out.contains("- key: DATABASE_URL"));
391 assert!(out.contains("- key: STRIPE_SECRET_KEY"));
392 assert!(out.contains("- key: APP_NAME"));
393 }
394
395 #[test]
396 fn secret_scope_and_type() {
397 let src = "DATABASE_URL=\nSTRIPE_SECRET_KEY=\nAPP_NAME=\n";
398 let out = render_envs_block(src);
399
400 let stripe_idx = out.find("- key: STRIPE_SECRET_KEY").unwrap();
401 let stripe_rest = &out[stripe_idx..];
402 let stripe_end = stripe_rest[1..]
404 .find("- key: ")
405 .map(|i| i + 1)
406 .unwrap_or(stripe_rest.len());
407 let stripe_slice = &stripe_rest[..stripe_end];
408 assert!(
409 stripe_slice.contains("type: SECRET"),
410 "STRIPE_SECRET_KEY must have type: SECRET, got: {stripe_slice}"
411 );
412 assert!(
413 stripe_slice.contains("scope: RUN_AND_BUILD_TIME"),
414 "STRIPE_SECRET_KEY must have scope: RUN_AND_BUILD_TIME"
415 );
416
417 let db_idx = out.find("- key: DATABASE_URL").unwrap();
418 let db_rest = &out[db_idx..];
419 let db_end = db_rest[1..]
420 .find("- key: ")
421 .map(|i| i + 1)
422 .unwrap_or(db_rest.len());
423 let db_slice = &db_rest[..db_end];
424 assert!(
425 !db_slice.contains("type: SECRET"),
426 "DATABASE_URL must NOT have type: SECRET"
427 );
428 assert!(
429 db_slice.contains("scope: RUN_TIME"),
430 "DATABASE_URL must have scope: RUN_TIME"
431 );
432 }
433
434 #[test]
435 fn envs_preserve_source_order() {
436 let src = "Z_NAME=\nA_NAME=\nM_NAME=\n";
437 let out = render_envs_block(src);
438 let z = out.find("Z_NAME").unwrap();
439 let a = out.find("A_NAME").unwrap();
440 let m = out.find("M_NAME").unwrap();
441 assert!(z < a && a < m);
442 }
443
444 #[test]
445 fn envs_preserve_blank_separators() {
446 let src = "A_NAME=\n\nB_NAME=\n";
447 let out = render_envs_block(src);
448 let a = out.find("- key: A_NAME").unwrap();
449 let b = out.find("- key: B_NAME").unwrap();
450 assert!(
451 out[a..b].contains("\n\n"),
452 "expected blank line separator between A_NAME and B_NAME"
453 );
454 }
455}
456
457#[cfg(test)]
458mod app_yaml_structure_tests {
459 use super::tests::ctx_without_env;
460 use super::*;
461
462 #[test]
463 fn web_service_has_no_run_command() {
464 let c = AppYamlContext {
465 name: "x".into(),
466 repo: "o/r".into(),
467 web_bin: "x".into(),
468 workers: Vec::new(),
469 env_lines: Some(vec![EnvLine::Key("APP_NAME".into())]),
470 preserved_name: None,
471 preserved_region: None,
472 preserved_github_repo: None,
473 preserved_github_branch: None,
474 };
475 let out = render_app_yaml(&c);
476 let services_idx = out.find("services:").expect("services: block");
478 let workers_idx = out[services_idx..]
479 .find("# workers:")
480 .map(|i| services_idx + i)
481 .unwrap_or(out.len());
482 let web_block = &out[services_idx..workers_idx];
483 assert!(
484 !web_block.contains("run_command:"),
485 "web service must not set run_command (D-05), got: {web_block}"
486 );
487 }
488
489 #[test]
490 fn web_service_has_entrypoint_comment() {
491 let c = AppYamlContext {
492 name: "x".into(),
493 repo: "o/r".into(),
494 web_bin: "x".into(),
495 workers: Vec::new(),
496 env_lines: Some(vec![EnvLine::Key("APP_NAME".into())]),
497 preserved_name: None,
498 preserved_region: None,
499 preserved_github_repo: None,
500 preserved_github_branch: None,
501 };
502 let out = render_app_yaml(&c);
503 assert!(
504 out.contains("Dockerfile ENTRYPOINT"),
505 "expected inline comment pointing at Dockerfile ENTRYPOINT"
506 );
507 }
508
509 #[test]
510 fn envs_missing_env_example_emits_empty_block() {
511 let c = ctx_without_env("x", "o/r");
512 let out = render_app_yaml(&c);
513 assert!(
514 !out.contains("- key: "),
515 "expected empty envs block when .env.example missing"
516 );
517 assert!(out.contains("envs:"));
519 }
520}