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