Skip to main content

ferro_cli/templates/
do.rs

1//! Phase 122.2 §4: `.do/app.yaml` starter renderer.
2//!
3//! Pure string substitution over `files/do/app.yaml.tpl`. Caller is
4//! responsible for I/O (reading Cargo.toml, .env.production, git remote).
5
6// Renderer is pure and I/O-free. Web-bin resolution uses
7// `crate::deploy::bin_detect::detect_web_bin` at the caller
8// (`commands/do_init.rs`) so that the Dockerfile ENTRYPOINT and the DO web
9// service stay in sync by construction (ferro 127, D-02).
10#[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
17/// Inputs for [`render_app_yaml`]. All fields are pre-resolved by the caller.
18pub struct AppYamlContext {
19    /// Sanitized DO app name (output of [`sanitize_do_app_name`]).
20    pub name: String,
21    /// `owner/repo` for `github.repo`, or fallback placeholder.
22    pub repo: String,
23    /// The bin matching the package name — kept for symmetry, not currently
24    /// emitted (the `web` service is hardcoded in the template).
25    pub web_bin: String,
26    /// Non-web, non-test-like bins to emit as workers.
27    pub workers: Vec<String>,
28    /// Structured `.env.example` lines (keys + blank separators). `None` when
29    /// `.env.example` is missing — the renderer emits an empty envs block and
30    /// the caller logs a warning (D-06 graceful missing-file path).
31    pub env_lines: Option<Vec<EnvLine>>,
32    /// Identity override: DO App Platform app name from an existing file.
33    /// When `Some`, takes precedence over `name` (derived from package name).
34    pub preserved_name: Option<String>,
35    /// Identity override: DO region slug from an existing file.
36    /// When `Some`, takes precedence over the default (`fra1`).
37    pub preserved_region: Option<String>,
38    /// Identity override: `github.repo` from an existing file.
39    /// When `Some`, takes precedence over the git-remote-derived `repo`.
40    pub preserved_github_repo: Option<String>,
41    /// Identity override: `github.branch` from an existing file.
42    /// When `Some`, takes precedence over the default (`main`).
43    pub preserved_github_branch: Option<String>,
44}
45
46/// Render `.do/app.yaml` from a fully-resolved context.
47pub 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    // Preserved identity fields take precedence over derived defaults.
55    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/// Test helper: render an envs block from raw `.env.example` contents.
80#[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 = "  "; // child of `envs:` — matches template indent level
89    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                // Comments in .env.example are dropped; blank-line separators
113                // carry the grouping into the generated YAML.
114            }
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        // SCOPE §4: emit a commented example so users see the shape.
143        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
167/// Sanitize a package name to a DigitalOcean-compatible app name.
168///
169/// Kept because DO rejects otherwise-valid names with a cryptic remote
170/// error; local sanitization gives a fast-fail with a clear reason. This is
171/// the only sanitizer surviving Phase 122.2.
172pub 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        // Other chars stripped.
182    }
183    // Collapse runs of dashes.
184    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
200/// Parse a git remote URL (HTTPS or SSH form) to `"owner/repo"`. GitHub only.
201pub 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
220/// Heuristic: filter test/dev/debug bins from the workers block.
221///
222/// This is the ONLY surviving heuristic after the Phase 122.2 simplification.
223/// Kept because the alternative (explicit workers list in
224/// `[package.metadata.ferro.deploy]`) would duplicate the `[[bin]]` entries
225/// the user already wrote in Cargo.toml. See Phase 122.2 SCOPE §4.
226pub 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        // Job present
351        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        // Command uses resolved web_bin
357        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        // Source repo/branch threaded through
366        assert!(
367            out.contains("repo: owner/myrepo"),
368            "repo not threaded:\n{out}"
369        );
370        // Don't auto-deploy from the job (it runs as part of the parent deploy)
371        assert!(
372            out.contains("deploy_on_push: false"),
373            "expected migrate job to set deploy_on_push: false:\n{out}"
374        );
375        // No unresolved tokens
376        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        // Slice up to the next `- key: ` (or end of string).
455        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        // Locate the web service block (between `services:` and the workers block).
529        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        // The `envs:` header itself still renders.
570        assert!(out.contains("envs:"));
571    }
572}