Skip to main content

mitosis/
templates.rs

1use crate::clone_backend::{apparent_size, fill_copy, CloneBackend};
2use crate::clones::START_TRIES;
3use crate::ctx::Ctx;
4use crate::error::{ErrorCode, MitosisError, Result};
5use crate::events;
6use crate::mask::{mask_statements, subset_statements, ColumnInfo, ForeignKey, MaskFile};
7use crate::names::{random_hex, validate_name};
8use crate::paths::Home;
9use crate::pg::{
10    disable_preload_libraries, ensure_postgres_superuser, initdb, is_missing_library,
11    make_standalone, psql, read_pg_version, start, stop, wait_ready, PgInstall, RunningPg,
12    StartOptions,
13};
14use crate::ports::pick_port;
15use crate::protocol::{
16    BranchArgs, EventFilter, GenKind, GenerationInfo, MigrateCmd, OnStale, RefreshMode,
17    TemplateInfo, TemplateInitArgs, TemplateSetArgs, TemplateSettings, TemplateSource,
18};
19use crate::state::{GenerationRow, TemplateRow};
20use nix::sys::signal::{killpg, Signal};
21use nix::unistd::Pid;
22use sha2::{Digest, Sha256};
23use std::collections::HashSet;
24use std::ffi::OsStr;
25use std::fs::OpenOptions;
26use std::io::Write;
27use std::os::unix::ffi::OsStrExt;
28use std::os::unix::fs::{DirBuilderExt, MetadataExt};
29use std::os::unix::process::CommandExt;
30use std::path::{Path, PathBuf};
31use std::process::{Command, Stdio};
32use std::sync::{Arc, Mutex};
33use std::time::{Duration, SystemTime, UNIX_EPOCH};
34
35const DEFAULT_SEED_DATABASE: &str = "app";
36/// The shortest refresh interval `--every` accepts.
37const MIN_EVERY_SECS: u64 = 60;
38
39fn internal(msg: impl Into<String>) -> MitosisError {
40    MitosisError::new(ErrorCode::Internal, msg)
41}
42
43fn invalid(msg: impl Into<String>) -> MitosisError {
44    MitosisError::new(ErrorCode::InvalidRequest, msg)
45}
46
47pub(crate) fn now() -> i64 {
48    SystemTime::now()
49        .duration_since(UNIX_EPOCH)
50        .map(|d| d.as_secs() as i64)
51        .unwrap_or(0)
52}
53
54/// Runs blocking work (Postgres tools, big file operations) off the async runtime.
55async fn blocking<T, F>(f: F) -> Result<T>
56where
57    F: FnOnce() -> Result<T> + Send + 'static,
58    T: Send + 'static,
59{
60    tokio::task::spawn_blocking(f)
61        .await
62        .map_err(|e| internal(format!("background task failed: {e}")))?
63}
64
65pub async fn init(ctx: &Ctx, mut args: TemplateInitArgs) -> Result<TemplateInfo> {
66    validate_name("template", &args.name)?;
67    if let TemplateSource::Url { url } | TemplateSource::Basebackup { url } = &args.source {
68        if !(url.starts_with("postgres://") || url.starts_with("postgresql://")) {
69            return Err(invalid(
70                "source URL must start with postgres:// or postgresql://",
71            ));
72        }
73        split_password(url)?;
74    }
75    if matches!(args.source, TemplateSource::Promoted { .. }) {
76        return Err(
77            invalid("a template cannot be created from a promoted source")
78                .with_hint("make one with: mitosis promote <clone> --template <name>"),
79        );
80    }
81    if let Some(m) = &args.migrations {
82        if !m.is_dir() {
83            return Err(invalid(format!(
84                "migrations folder {} does not exist",
85                m.display()
86            )));
87        }
88    }
89    // An existing template gets a new generation from the new source, on the same database.
90    let existing = ctx.store().get_template(&args.name)?;
91    if let Some(t) = &existing {
92        if args.database.is_none()
93            && matches!(
94                args.source,
95                TemplateSource::Seed { .. } | TemplateSource::Running { .. }
96            )
97        {
98            args.database = Some(t.database.clone());
99        }
100        let db = database_for(&args)?;
101        if db != t.database {
102            return Err(invalid(format!(
103                "template {} uses database {}, not {db}",
104                t.name, t.database
105            ))
106            .with_hint(format!(
107                "use another template name, or keep the database {}",
108                t.database
109            )));
110        }
111    }
112    // Settings are checked before the build and applied with its generation.
113    let mut preview =
114        existing.unwrap_or_else(|| TemplateRow::new(&args.name, "", args.source.clone(), None, 0));
115    preview.migrations = args.migrations.clone();
116    preview.source = args.source.clone();
117    apply_settings(&mut preview, None, &args.settings)?;
118    check_mask_file(&args.settings)?;
119    // The checkout as it is now, recorded once the build is in.
120    let (cwd, mig) = (
121        preview.migrate.as_ref().map(|m| m.cwd.clone()),
122        preview.migrations.clone(),
123    );
124    let paths = blocking(move || Ok(git_paths(cwd.as_deref(), mig.as_deref()))).await?;
125    let (name, explicit_branch) = (args.name.clone(), args.settings.base_branch.is_some());
126    build(ctx, args).await?;
127    {
128        let store = ctx.store();
129        let mut t = store.get_template(&name)?.ok_or_else(|| not_found(&name))?;
130        record_git_paths(&mut t, paths, explicit_branch);
131        store.update_template_settings(&t)?;
132    }
133    template_info(ctx, &name)
134}
135
136/// What `git_paths` found: the checked-out branch and the repo-relative migrate folder and
137/// migrations folder.
138type GitPaths = (Option<String>, Option<PathBuf>, Option<PathBuf>);
139
140/// Forgets the repo-relative paths, which belong to the migrate and migrations folders recorded
141/// when they were derived.
142fn clear_repo_paths(t: &mut TemplateRow) {
143    (t.repo_rel_cwd, t.repo_rel_migrations) = (None, None);
144}
145
146/// Writes `paths` into `t`; `base_branch` stays when `keep_branch` (it was given explicitly).
147fn record_git_paths(t: &mut TemplateRow, paths: GitPaths, keep_branch: bool) {
148    if !keep_branch {
149        t.base_branch = paths.0;
150    }
151    (t.repo_rel_cwd, t.repo_rel_migrations) = (paths.1, paths.2);
152}
153
154/// Runs `git -C <dir> <args>`; its trimmed output when it succeeds, `None` when git fails or is
155/// missing. Blocking.
156pub(crate) fn git(dir: &Path, args: &[&str]) -> Option<String> {
157    let out = Command::new("git")
158        .arg("-C")
159        .arg(dir)
160        .args(args)
161        .stdin(Stdio::null())
162        .stderr(Stdio::null())
163        .output()
164        .ok()?;
165    out.status
166        .success()
167        .then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
168}
169
170/// The branch checked out in the git work tree of `cwd` (else of `migrations`), and each folder
171/// relative to the root of its work tree; `None` for what is not in a work tree. Blocking.
172fn git_paths(cwd: Option<&Path>, migrations: Option<&Path>) -> GitPaths {
173    let rel = |dir: &Path| {
174        let top = git(dir, &["rev-parse", "--show-toplevel"])?;
175        let top = Path::new(&top).canonicalize().ok()?;
176        let dir = dir.canonicalize().ok()?;
177        dir.strip_prefix(&top).ok().map(Path::to_path_buf)
178    };
179    let (rel_cwd, rel_mig) = (cwd.and_then(rel), migrations.and_then(rel));
180    let branch = cwd
181        .filter(|_| rel_cwd.is_some())
182        .or(migrations.filter(|_| rel_mig.is_some()))
183        .and_then(|d| git(d, &["branch", "--show-current"]))
184        .filter(|b| !b.is_empty());
185    (branch, rel_cwd, rel_mig)
186}
187
188/// A masking file given in a request must load. Checked at request time only: the settings
189/// written after a build never fail on a file edited meanwhile.
190fn check_mask_file(s: &TemplateSettings) -> Result<()> {
191    match &s.mask {
192        Some(p) => MaskFile::load(p).map(drop),
193        None => Ok(()),
194    }
195}
196
197pub(crate) fn not_found(name: &str) -> MitosisError {
198    MitosisError::new(
199        ErrorCode::TemplateNotFound,
200        format!("template {name} not found"),
201    )
202    .with_hint("see existing templates with: mitosis template list")
203}
204
205/// Rebuilds `name` from its recorded source. `path`, when given, replaces a seed's recorded `PATH`.
206pub async fn refresh(ctx: &Ctx, name: &str, path: Option<String>) -> Result<TemplateInfo> {
207    validate_name("template", name)?;
208    let args = refresh_args(ctx, name, path)?;
209    build(ctx, args).await
210}
211
212/// The build arguments that rebuild `name` from its recorded source on the current major.
213fn refresh_args(ctx: &Ctx, name: &str, path: Option<String>) -> Result<TemplateInitArgs> {
214    let (row, cur) = ctx.store().current(name)?.ok_or_else(|| not_found(name))?;
215    if matches!(row.source, TemplateSource::Promoted { .. }) {
216        return Err(invalid(format!(
217            "template {name} was promoted from a clone and has no source to rebuild"
218        ))
219        .with_hint("use --layered or promote again"));
220    }
221    let source = match row.source {
222        TemplateSource::Seed {
223            command,
224            cwd,
225            path: recorded,
226        } => TemplateSource::Seed {
227            command,
228            cwd,
229            path: path.or(recorded),
230        },
231        url => url,
232    };
233    Ok(TemplateInitArgs {
234        name: row.name,
235        source,
236        database: Some(row.database),
237        migrations: row.migrations,
238        // None: a `template set --max-age` made during the build must survive it.
239        max_age_secs: None,
240        pg_major: Some(cur.pg_major),
241        settings: TemplateSettings::default(),
242    })
243}
244
245/// Changes the settings of `name`; `resume` clears `paused`.
246pub fn set(ctx: &Ctx, args: TemplateSetArgs) -> Result<TemplateInfo> {
247    validate_name("template", &args.name)?;
248    if args.max_age_secs.is_none() && !args.resume && args.settings == TemplateSettings::default() {
249        return Err(invalid("template set needs a setting to change")
250            .with_hint("for example: mitosis template set <name> --keep 5"));
251    }
252    check_mask_file(&args.settings)?;
253    {
254        let store = ctx.store();
255        let mut t = store
256            .get_template(&args.name)?
257            .ok_or_else(|| not_found(&args.name))?;
258        apply_settings(&mut t, args.max_age_secs, &args.settings)?;
259        if args.resume {
260            t.paused = false;
261            // The last failure stays visible, but it no longer holds the next automatic build back.
262            t.last_attempt_at = None;
263        }
264        store.update_template_settings(&t)?;
265    }
266    template_info(ctx, &args.name)
267}
268
269/// Checks `s` and writes it, and `max_age_secs` when given, into `t`.
270fn apply_settings(
271    t: &mut TemplateRow,
272    max_age_secs: Option<u64>,
273    s: &TemplateSettings,
274) -> Result<()> {
275    if s.every_secs.is_some() && s.every_off {
276        return Err(invalid("set --every to a duration or to off, not both"));
277    }
278    if s.migrate.is_some() && s.migrate_off {
279        return Err(invalid("set --migrate to a command or to off, not both"));
280    }
281    if s.mask.is_some() && s.mask_off {
282        return Err(invalid("set --mask to a file or to off, not both"));
283    }
284    if s.base_branch.is_some() && s.base_branch_off {
285        return Err(invalid("set --base-branch to a branch or to off, not both"));
286    }
287    if s.keep == Some(0) {
288        return Err(invalid("--keep must be at least 1"));
289    }
290    if s.every_secs.is_some_and(|e| e < MIN_EVERY_SECS) {
291        return Err(invalid("--every must be at least 1m"));
292    }
293    if s.migrate
294        .as_ref()
295        .is_some_and(|m| m.command.trim().is_empty())
296    {
297        return Err(invalid("--migrate needs a command, or off"));
298    }
299    if let Some(m) = s.migrate.as_ref().filter(|m| !m.cwd.is_dir()) {
300        return Err(invalid(format!(
301            "--migrate would run in {}, which is not a folder",
302            m.cwd.display()
303        )));
304    }
305    if let Some(v) = max_age_secs {
306        t.max_age_secs = v;
307    }
308    if let Some(k) = s.keep {
309        t.keep = k;
310    }
311    if s.every_off {
312        t.every_secs = None;
313    } else if s.every_secs.is_some() {
314        t.every_secs = s.every_secs;
315    }
316    if let Some(m) = s.every_mode {
317        t.every_mode = m;
318    }
319    if s.migrate_off {
320        t.migrate = None;
321    } else if s.migrate.is_some() {
322        t.migrate = s.migrate.clone();
323    }
324    if s.migrate_off || s.migrate.is_some() {
325        // Re-derived from the new folder at the next `clone --branch` (or by `template init`).
326        clear_repo_paths(t);
327    }
328    if let Some(a) = s.auto_migrate {
329        t.auto_migrate = a;
330    }
331    if s.mask_off {
332        t.mask_path = None;
333    } else if s.mask.is_some() {
334        t.mask_path = s.mask.clone();
335    }
336    if let Some(o) = s.on_stale {
337        t.on_stale = o;
338    }
339    if s.base_branch_off {
340        t.base_branch = None;
341    } else if let Some(b) = &s.base_branch {
342        if b.trim().is_empty() {
343            return Err(invalid("--base-branch needs a branch name, or off"));
344        }
345        t.base_branch = Some(b.clone());
346    }
347    // A full refresh of a promoted template is refused, so a schedule must be layered.
348    if matches!(t.source, TemplateSource::Promoted { .. })
349        && t.every_secs.is_some()
350        && t.every_mode == RefreshMode::Full
351    {
352        return Err(invalid(
353            "--every with --every-mode full needs a source, and a promoted template has none",
354        )
355        .with_hint("use --every-mode layered"));
356    }
357    if t.auto_migrate && t.migrations.is_none() {
358        return Err(
359            invalid("--auto-migrate on needs --migrations").with_hint(format!(
360            "record the migrations folder with: mitosis template init {} --migrations <folder> ...",
361            t.name
362        )),
363        );
364    }
365    if t.auto_migrate && t.migrate.is_none() {
366        return Err(invalid("--auto-migrate on needs --migrate")
367            .with_hint(format!("mitosis template set {} --migrate \"…\"", t.name)));
368    }
369    Ok(())
370}
371
372/// Templates with a current generation; one still building its first is left out.
373pub fn list(ctx: &Ctx) -> Result<Vec<TemplateInfo>> {
374    let store = ctx.store();
375    let now = now();
376    let mut out = Vec::new();
377    for t in store.list_templates()? {
378        let gens = store.list_generations(&t.name)?;
379        if let Some(cur) = gens.iter().find(|g| g.gen == t.current_gen) {
380            out.push(info(&t, cur, gens.len() as u32, now));
381        }
382    }
383    Ok(out)
384}
385
386/// The template with its current generation `cur`, out of `generations` kept.
387pub(crate) fn template_info(ctx: &Ctx, name: &str) -> Result<TemplateInfo> {
388    let store = ctx.store();
389    let (t, cur) = store.current(name)?.ok_or_else(|| not_found(name))?;
390    let n = store.list_generations(name)?.len() as u32;
391    Ok(info(&t, &cur, n, now()))
392}
393
394pub(crate) fn age_secs(built_at: i64, now: i64) -> u64 {
395    now.saturating_sub(built_at).max(0) as u64
396}
397
398pub fn info(row: &TemplateRow, cur: &GenerationRow, generations: u32, now: i64) -> TemplateInfo {
399    let age_secs = age_secs(cur.built_at, now);
400    let mut warnings = Vec::new();
401    if age_secs > row.max_age_secs {
402        // A branch template is rebuilt from its base, so the base is what to refresh.
403        warnings.push(format!(
404            "template is {} days old; run: mitosis template refresh {}",
405            age_secs / 86_400,
406            row.base.as_deref().unwrap_or(&row.name)
407        ));
408    }
409    if let Some(dir) = row.migrations.as_deref().filter(|d| d.is_dir()) {
410        if hash_dir(dir).ok() != cur.migrations_hash {
411            warnings.push(format!(
412                "migrations in {} changed since the template was built",
413                dir.display()
414            ));
415        }
416    }
417    if let Some(p) = &row.mask_path {
418        if MaskFile::hash(p).ok() != cur.mask_hash {
419            warnings.push(format!(
420                "mask file {} changed since the template was built",
421                p.display()
422            ));
423        }
424    }
425    let source = match &row.source {
426        TemplateSource::Url { url } => TemplateSource::Url {
427            url: redact_url(url),
428        },
429        TemplateSource::Basebackup { url } => TemplateSource::Basebackup {
430            url: redact_url(url),
431        },
432        TemplateSource::Seed { command, cwd, path } => TemplateSource::Seed {
433            command: redact_url(command),
434            cwd: cwd.clone(),
435            path: path.clone(),
436        },
437        other @ (TemplateSource::Promoted { .. } | TemplateSource::Running { .. }) => other.clone(),
438    };
439    TemplateInfo {
440        name: row.name.clone(),
441        pg_major: cur.pg_major,
442        source,
443        database: row.database.clone(),
444        size_bytes: cur.size_bytes,
445        built_at: cur.built_at,
446        age_secs,
447        warnings,
448        current_gen: row.current_gen,
449        generations,
450        paused: row.paused,
451        last_error: row.last_error.clone(),
452        every_secs: row.every_secs,
453        every_mode: row.every_mode,
454        migrate: row.migrate.as_ref().map(|m| redact_url(&m.command)),
455        auto_migrate: row.auto_migrate,
456        on_stale: row.on_stale,
457        keep: row.keep,
458        mask: row.mask_path.clone(),
459        base: row.base.clone(),
460        branch: row.branch.clone(),
461        base_branch: row.base_branch.clone(),
462    }
463}
464
465pub fn history(ctx: &Ctx, name: &str) -> Result<Vec<GenerationInfo>> {
466    validate_template_ref(name)?;
467    let store = ctx.store();
468    let t = store.get_template(name)?.ok_or_else(|| not_found(name))?;
469    Ok(store
470        .list_generations(name)?
471        .into_iter()
472        .map(|g| GenerationInfo {
473            gen: g.gen,
474            kind: g.kind,
475            built_at: g.built_at,
476            pg_major: g.pg_major,
477            size_bytes: g.size_bytes,
478            current: g.gen == t.current_gen,
479            from_clone: g.from_clone,
480            from_gen: g.from_gen,
481        })
482        .collect())
483}
484
485/// Points `name` at generation `to` (default: the newest older than the current one) and pauses
486/// scheduled refreshes, so the rollback sticks.
487pub async fn rollback(ctx: &Ctx, name: &str, to: Option<u32>) -> Result<TemplateInfo> {
488    validate_name("template", name)?;
489    let (cur, gens) = {
490        let store = ctx.store();
491        let (_, cur) = store.current(name)?.ok_or_else(|| not_found(name))?;
492        (cur, store.list_generations(name)?)
493    };
494    let hint = || format!("see generations with: mitosis template history {name}");
495    let target = match to {
496        Some(n) => gens.into_iter().find(|g| g.gen == n).ok_or_else(|| {
497            invalid(format!("template {name} has no generation {n}")).with_hint(hint())
498        })?,
499        None => gens
500            .into_iter()
501            .rev()
502            .find(|g| g.gen < cur.gen)
503            .ok_or_else(|| {
504                invalid(format!(
505                    "template {name} has no generation older than {}",
506                    cur.gen
507                ))
508                .with_hint(hint())
509            })?,
510    };
511    // Clones of the target need its Postgres major.
512    ctx.install_major(target.pg_major).await?;
513    {
514        let store = ctx.store();
515        if !store.set_current(name, target.gen, Some(cur.gen))? {
516            return Err(invalid(format!(
517                "template {name} changed during the rollback; try again"
518            )));
519        }
520        let mut t = store.get_template(name)?.ok_or_else(|| not_found(name))?;
521        t.paused = true;
522        store.update_template_settings(&t)?;
523    }
524    events::emit(
525        ctx,
526        events::TEMPLATE_ROLLED_BACK,
527        None,
528        Some(name),
529        serde_json::json!({ "from": cur.gen, "to": target.gen }),
530    );
531    template_info(ctx, name)
532}
533
534/// Masks the password of every `scheme://user:password@host` and every `?password=` query value as `***`.
535pub fn redact_url(text: &str) -> String {
536    let mut out = String::with_capacity(text.len());
537    let mut rest = text;
538    while let Some(i) = rest.find("://") {
539        let (head, tail) = rest.split_at(i + 3);
540        out.push_str(head);
541        let end = tail
542            .find(|c: char| matches!(c, '/' | '?' | '#' | '\'' | '"') || c.is_whitespace())
543            .unwrap_or(tail.len());
544        let authority = &tail[..end];
545        match authority
546            .rfind('@')
547            .and_then(|at| authority[..at].find(':').map(|colon| (colon, at)))
548        {
549            Some((colon, at)) => {
550                out.push_str(&authority[..colon]);
551                out.push_str(":***");
552                out.push_str(&authority[at..]);
553            }
554            None => out.push_str(authority),
555        }
556        let after = &tail[end..];
557        let url_end = after
558            .find(|c: char| matches!(c, '\'' | '"') || c.is_whitespace())
559            .unwrap_or(after.len());
560        out.push_str(&mask_query_password(&after[..url_end]));
561        rest = &after[url_end..];
562    }
563    out.push_str(rest);
564    mask_env_passwords(&out)
565}
566
567/// Masks the value of every `NAME_PASSWORD=<value>` assignment (`PGPASSWORD=secret psql ...`),
568/// quoted or not, as `***`.
569fn mask_env_passwords(text: &str) -> String {
570    const KEY: &str = "PASSWORD=";
571    let mut out = String::with_capacity(text.len());
572    let mut rest = text;
573    while let Some(i) = rest.find(KEY) {
574        let (head, tail) = rest.split_at(i + KEY.len());
575        out.push_str(head);
576        let value_len = match tail.chars().next() {
577            Some(q @ ('\'' | '"')) => tail[1..].find(q).map_or(tail.len(), |e| e + 2),
578            _ => tail.find(char::is_whitespace).unwrap_or(tail.len()),
579        };
580        if value_len > 0 {
581            out.push_str("***");
582        }
583        rest = &tail[value_len..];
584    }
585    out.push_str(rest);
586    out
587}
588
589/// Masks every `password=<value>` query parameter of the URL tail `s` (path, query, fragment).
590fn mask_query_password(s: &str) -> String {
591    let Some(q) = s.find('?') else {
592        return s.to_string();
593    };
594    let (path, query) = s.split_at(q + 1);
595    let (query, frag) = query.split_at(query.find('#').unwrap_or(query.len()));
596    let masked: Vec<&str> = query
597        .split('&')
598        .map(|p| {
599            if p.starts_with("password=") {
600                "password=***"
601            } else {
602                p
603            }
604        })
605        .collect();
606    format!("{path}{}{frag}", masked.join("&"))
607}
608
609/// A source URL with its password taken out, so the password can travel in `PGPASSWORD`.
610#[derive(Debug, PartialEq)]
611struct SplitUrl {
612    /// The URL without the password (`postgres://u@h/db`).
613    url: String,
614    /// Percent-decoded password, as libpq would read it.
615    password: Option<String>,
616    /// The password exactly as written in the URL.
617    raw_password: Option<String>,
618}
619
620fn percent_decode(s: &str) -> Option<String> {
621    let b = s.as_bytes();
622    let mut out = Vec::with_capacity(b.len());
623    let mut i = 0;
624    while i < b.len() {
625        if b[i] == b'%' {
626            let hex = b
627                .get(i + 1..i + 3)
628                .filter(|h| h.iter().all(u8::is_ascii_hexdigit))?;
629            out.push(u8::from_str_radix(std::str::from_utf8(hex).ok()?, 16).ok()?);
630            i += 3;
631        } else {
632            out.push(b[i]);
633            i += 1;
634        }
635    }
636    String::from_utf8(out).ok()
637}
638
639/// Splits the password out of `url`: the userinfo one or a `?password=` query parameter
640/// (the query one wins, as in libpq). Bad percent-encoding is an error that never echoes the password.
641fn split_password(url: &str) -> Result<SplitUrl> {
642    let plain = || SplitUrl {
643        url: url.to_string(),
644        password: None,
645        raw_password: None,
646    };
647    let Some(i) = url.find("://") else {
648        return Ok(plain());
649    };
650    let (head, tail) = url.split_at(i + 3);
651    let end = tail.find(['/', '?', '#']).unwrap_or(tail.len());
652    let authority = &tail[..end];
653    let mut raw: Option<String> = None;
654    let mut authority_out = authority.to_string();
655    if let Some(at) = authority.rfind('@') {
656        if let Some(colon) = authority[..at].find(':') {
657            raw = Some(authority[colon + 1..at].to_string());
658            authority_out = format!("{}{}", &authority[..colon], &authority[at..]);
659        }
660    }
661    let mut rest = tail[end..].to_string();
662    if let Some(q) = rest.find('?') {
663        let frag = rest[q..].find('#').map_or(rest.len(), |f| q + f);
664        let mut kept = Vec::new();
665        for p in rest[q + 1..frag].split('&').filter(|p| !p.is_empty()) {
666            match p.strip_prefix("password=") {
667                Some(v) => raw = Some(v.to_string()),
668                None => kept.push(p),
669            }
670        }
671        let query = if kept.is_empty() {
672            String::new()
673        } else {
674            format!("?{}", kept.join("&"))
675        };
676        let joined = format!("{}{query}{}", &rest[..q], &rest[frag..]);
677        rest = joined;
678    }
679    let Some(raw) = raw else {
680        return Ok(plain());
681    };
682    let password = percent_decode(&raw)
683        .ok_or_else(|| invalid("the password in the source URL has invalid percent-encoding"))?;
684    Ok(SplitUrl {
685        url: format!("{head}{authority_out}{rest}"),
686        password: Some(password),
687        raw_password: Some(raw),
688    })
689}
690
691/// Literal secrets of a source (the URL password, decoded and raw), longest first.
692fn source_secrets(source: &TemplateSource) -> Vec<String> {
693    let (TemplateSource::Url { url } | TemplateSource::Basebackup { url }) = source else {
694        return Vec::new();
695    };
696    let Ok(split) = split_password(url) else {
697        return Vec::new();
698    };
699    let mut s: Vec<String> = [split.password, split.raw_password]
700        .into_iter()
701        .flatten()
702        .filter(|p| !p.is_empty())
703        .collect();
704    s.sort_by_key(|p| std::cmp::Reverse(p.len()));
705    s.dedup();
706    s
707}
708
709/// `redact_url` plus removal of the literal secrets, for anything shown to the user.
710fn scrub(text: &str, secrets: &[String]) -> String {
711    secrets
712        .iter()
713        .fold(redact_url(text), |t, s| t.replace(s.as_str(), "***"))
714}
715
716pub(crate) fn fnv1a(h: &mut u64, bytes: &[u8]) {
717    for b in bytes {
718        *h ^= *b as u64;
719        *h = h.wrapping_mul(0x0100_0000_01b3);
720    }
721}
722
723fn collect_files(root: &Path, rel: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
724    let dir = root.join(rel);
725    let rd =
726        std::fs::read_dir(&dir).map_err(|e| internal(format!("read {}: {e}", dir.display())))?;
727    for entry in rd {
728        let entry = entry?;
729        let rel = rel.join(entry.file_name());
730        if entry.file_type()?.is_dir() {
731            collect_files(root, &rel, out)?;
732        } else if entry.path().is_file() {
733            out.push(rel); // regular files and symlinks to files; symlinked folders are skipped
734        }
735    }
736    Ok(())
737}
738
739/// FNV-1a 64 over the sorted relative paths and contents of every file under `dir`, as hex.
740pub fn hash_dir(dir: &Path) -> Result<String> {
741    let mut files = Vec::new();
742    collect_files(dir, Path::new(""), &mut files)?;
743    files.sort();
744    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
745    for rel in files {
746        let path = dir.join(&rel);
747        let data =
748            std::fs::read(&path).map_err(|e| internal(format!("read {}: {e}", path.display())))?;
749        fnv1a(&mut h, rel.as_os_str().as_bytes());
750        fnv1a(&mut h, &[0]);
751        fnv1a(&mut h, &data);
752        fnv1a(&mut h, &[0]);
753    }
754    Ok(format!("{h:016x}"))
755}
756
757// shortcut: no percent-decoding of the path; upgrade if sources use encoded database names.
758fn db_from_url(url: &str) -> String {
759    let after_scheme = url.split_once("://").map_or(url, |(_, r)| r);
760    let path = after_scheme.split_once('/').map_or("", |(_, p)| p);
761    let db = path.split(['?', '#']).next().unwrap_or("");
762    if db.is_empty() {
763        "postgres".into()
764    } else {
765        db.into()
766    }
767}
768
769fn database_for(args: &TemplateInitArgs) -> Result<String> {
770    let db = match &args.source {
771        TemplateSource::Url { url } => db_from_url(url),
772        TemplateSource::Basebackup { url } => {
773            args.database.clone().unwrap_or_else(|| db_from_url(url))
774        }
775        // A copy of the whole cluster, like a basebackup without a URL to name a database.
776        TemplateSource::Running { .. } => {
777            args.database.clone().unwrap_or_else(|| "postgres".into())
778        }
779        TemplateSource::Seed { .. } | TemplateSource::Promoted { .. } => args
780            .database
781            .clone()
782            .unwrap_or_else(|| DEFAULT_SEED_DATABASE.into()),
783    };
784    // psql and pg_restore read a -d value with '=' or a URI prefix as a connection string.
785    if db.is_empty() || db.contains('=') || db.contains("://") || db.contains('\0') {
786        return Err(invalid(format!("invalid database name {db:?}")));
787    }
788    Ok(db)
789}
790
791fn quote_ident(s: &str) -> String {
792    format!("\"{}\"", s.replace('"', "\"\""))
793}
794
795pub(crate) fn url_encode(s: &str) -> String {
796    s.bytes()
797        .map(|b| {
798            if b.is_ascii_alphanumeric() || b"-._~".contains(&b) {
799                (b as char).to_string()
800            } else {
801                format!("%{b:02X}")
802            }
803        })
804        .collect()
805}
806
807/// Appends one line to the build log, with any URL password masked. Best effort.
808fn append_log(log: &Path, text: &str) {
809    if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(log) {
810        let _ = writeln!(f, "{}", redact_url(text));
811    }
812}
813
814fn log_tail(log: &Path, n: usize) -> String {
815    let bytes = std::fs::read(log).unwrap_or_default();
816    let text = String::from_utf8_lossy(&bytes);
817    let lines: Vec<&str> = text.lines().collect();
818    lines[lines.len().saturating_sub(n)..].join("\n")
819}
820
821/// One build per template name at a time; released on drop.
822pub(crate) struct BuildGuard<'a> {
823    ctx: &'a Ctx,
824    name: String,
825}
826
827impl<'a> BuildGuard<'a> {
828    pub(crate) fn take(ctx: &'a Ctx, name: &str) -> Result<BuildGuard<'a>> {
829        if !ctx
830            .building
831            .lock()
832            .unwrap_or_else(|e| e.into_inner())
833            .insert(name.to_string())
834        {
835            return Err(invalid(format!("template {name} is already being built")));
836        }
837        Ok(BuildGuard {
838            ctx,
839            name: name.to_string(),
840        })
841    }
842}
843
844impl Drop for BuildGuard<'_> {
845    fn drop(&mut self) {
846        self.ctx
847            .building
848            .lock()
849            .unwrap_or_else(|e| e.into_inner())
850            .remove(&self.name);
851    }
852}
853
854/// Takes the build slot of `name` for a `background` build; false when a build of it runs.
855pub(crate) fn claim_build(ctx: &Ctx, name: &str) -> bool {
856    ctx.building
857        .lock()
858        .unwrap_or_else(|e| e.into_inner())
859        .insert(name.to_string())
860}
861
862/// True while a build of `name` runs (holds its build slot).
863pub fn is_building(ctx: &Ctx, name: &str) -> bool {
864    ctx.building
865        .lock()
866        .unwrap_or_else(|e| e.into_inner())
867        .contains(name)
868}
869
870/// True when `name` has a masking file its current generation was not built with (or that
871/// cannot be read): only a full build may make its next generation.
872fn mask_stale(ctx: &Ctx, name: &str) -> Result<bool> {
873    let Some((t, cur)) = ctx.store().current(name)? else {
874        return Ok(false);
875    };
876    Ok(t.mask_path
877        .is_some_and(|p| MaskFile::hash(&p).ok() != cur.mask_hash))
878}
879
880/// A scheduled or stale-triggered build of `name` in `mode`. The caller took the build slot with
881/// `claim_build`; it is released when this returns. Never clears `paused`. The outcome is stored
882/// before the slot is released, so no new attempt can start without seeing it: `last_attempt_at`
883/// is set to the start; a failure stores its scrubbed message in `last_error`, a success clears it.
884pub(crate) async fn background(ctx: &Ctx, name: &str, mode: RefreshMode) {
885    // Adopts the slot the caller claimed.
886    let _guard = BuildGuard {
887        ctx,
888        name: name.to_string(),
889    };
890    let started = now();
891    // The base follows only its own branch: a layered build from another checkout would pollute it.
892    if mode == RefreshMode::Layered {
893        match off_base_branch(ctx, name).await {
894            Ok(Some((base_branch, current))) => {
895                emit_skipped(ctx, name, &base_branch, &current);
896                ctx.layered_skips
897                    .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
898                return;
899            }
900            Ok(None) => {}
901            Err(e) => crate::lifecycle::log(ctx, &format!("template {name}: branch check: {e}")),
902        }
903    }
904    // A layered build would be refused while the masking file is newer than the current
905    // generation; an automatic build does the full build that re-bases it instead.
906    let mode = match mode {
907        RefreshMode::Layered if mask_stale(ctx, name).unwrap_or(false) => RefreshMode::Full,
908        m => m,
909    };
910    let res = match mode {
911        RefreshMode::Full => match refresh_args(ctx, name, None) {
912            Ok(args) => build_held(ctx, args, false).await,
913            Err(e) => Err(e),
914        },
915        RefreshMode::Layered => layered_held(ctx, name, None, false).await,
916    };
917    let store = ctx.store();
918    let mut t = match store.get_template(name) {
919        Ok(Some(t)) => t,
920        Ok(None) => return,
921        Err(e) => {
922            drop(store);
923            return crate::lifecycle::log(
924                ctx,
925                &format!("template {name}: read after refresh: {e}"),
926            );
927        }
928    };
929    t.last_attempt_at = Some(started);
930    t.last_error = res.as_ref().err().map(|e| {
931        // The first line names the failed step; the hint points at the full log.
932        let first = e.message.lines().next().unwrap_or_default();
933        let msg = match &e.hint {
934            Some(h) => format!("{first} ({h})"),
935            None => first.to_string(),
936        };
937        scrub(&msg, &source_secrets(&t.source))
938    });
939    let saved = store.update_template_settings(&t);
940    drop(store);
941    let mode = serde_json::to_value(mode)
942        .ok()
943        .and_then(|v| v.as_str().map(String::from))
944        .unwrap_or_default();
945    let outcome = match (&res, &t.last_error) {
946        (Ok(i), _) => format!("done, current generation {}", i.current_gen),
947        (Err(_), Some(m)) => format!("failed: {m}"),
948        (Err(_), None) => "failed".into(),
949    };
950    crate::lifecycle::log(
951        ctx,
952        &format!("background {mode} refresh of template {name}: {outcome}"),
953    );
954    if let Err(e) = saved {
955        crate::lifecycle::log(ctx, &format!("template {name}: store refresh result: {e}"));
956    }
957}
958
959type StepResult<T> = std::result::Result<T, (&'static str, MitosisError)>;
960
961fn at(step: &'static str) -> impl FnOnce(MitosisError) -> (&'static str, MitosisError) {
962    move |e| (step, e)
963}
964
965async fn run_psql(
966    install: &PgInstall,
967    opts: &StartOptions,
968    db: &str,
969    sql: String,
970) -> Result<String> {
971    let (i, s, p, d) = (
972        install.clone(),
973        opts.socket_dir.clone(),
974        opts.port,
975        db.to_string(),
976    );
977    blocking(move || psql(&i, &s, p, &d, &sql)).await
978}
979
980type Groups = Arc<Mutex<HashSet<i32>>>;
981
982/// Lists a build tool's process group in `Ctx::build_groups` until dropped, so daemon shutdown
983/// can kill it; `kill` ends whatever the tool left running in its group.
984struct GroupGuard {
985    groups: Groups,
986    pgid: i32,
987}
988
989impl GroupGuard {
990    fn new(groups: &Groups, pgid: i32) -> GroupGuard {
991        groups
992            .lock()
993            .unwrap_or_else(|e| e.into_inner())
994            .insert(pgid);
995        GroupGuard {
996            groups: groups.clone(),
997            pgid,
998        }
999    }
1000
1001    fn kill(&self) {
1002        let _ = killpg(Pid::from_raw(self.pgid), Signal::SIGKILL);
1003    }
1004}
1005
1006impl Drop for GroupGuard {
1007    fn drop(&mut self) {
1008        self.groups
1009            .lock()
1010            .unwrap_or_else(|e| e.into_inner())
1011            .remove(&self.pgid);
1012    }
1013}
1014
1015/// Kills every running seed command and dump/restore; the daemon calls it as it shuts down.
1016pub(crate) fn kill_build_groups(ctx: &Ctx) {
1017    let groups: Vec<i32> = ctx
1018        .build_groups
1019        .lock()
1020        .unwrap_or_else(|e| e.into_inner())
1021        .drain()
1022        .collect();
1023    for pgid in groups.into_iter().filter(|p| *p > 1) {
1024        let _ = killpg(Pid::from_raw(pgid), Signal::SIGKILL);
1025    }
1026}
1027
1028/// `pg_dump -Fc <url> | pg_restore` into the build server, both stderrs to the log.
1029/// The password goes in `PGPASSWORD`, never in argv, so neither `ps` nor libpq errors show it.
1030/// Without one in the URL, pg_dump reads `~/.pgpass` or `PGPASSWORD` from the daemon's environment.
1031fn dump_restore(
1032    install: &PgInstall,
1033    url: &str,
1034    opts: &StartOptions,
1035    db: &str,
1036    groups: &Groups,
1037) -> Result<()> {
1038    let split = split_password(url)?;
1039    let log = || OpenOptions::new().append(true).open(&opts.log_file);
1040    let (dump_err, restore_out, restore_err) = (log()?, log()?, log()?);
1041    let mut dump = Command::new(install.bin("pg_dump"));
1042    if let Some(pw) = &split.password {
1043        dump.env("PGPASSWORD", pw);
1044    }
1045    let mut dump = dump
1046        .args(["-Fc", "--no-owner", "--no-acl"])
1047        .arg(&split.url)
1048        .env("PGCONNECT_TIMEOUT", "10")
1049        .process_group(0)
1050        .stdin(Stdio::null())
1051        .stdout(Stdio::piped())
1052        .stderr(dump_err)
1053        .spawn()
1054        .map_err(|e| internal(format!("could not run pg_dump: {e}")))?;
1055    let _dump_group = GroupGuard::new(groups, dump.id() as i32);
1056    let dump_out = dump.stdout.take().expect("pg_dump stdout is piped");
1057    // If pg_restore cannot start, the pipe closes and pg_dump exits, so the wait below cannot hang.
1058    let restore = Command::new(install.bin("pg_restore"))
1059        .args(["--no-owner", "--no-acl", "-h"])
1060        .arg(&opts.socket_dir)
1061        .args(["-p", &opts.port.to_string(), "-U", "postgres", "-d", db])
1062        .process_group(0)
1063        .stdin(dump_out)
1064        .stdout(restore_out)
1065        .stderr(restore_err)
1066        .spawn()
1067        .map_err(|e| internal(format!("could not run pg_restore: {e}")));
1068    let restore = restore.and_then(|mut child| {
1069        let _restore_group = GroupGuard::new(groups, child.id() as i32);
1070        child
1071            .wait()
1072            .map_err(|e| internal(format!("waiting for pg_restore: {e}")))
1073    });
1074    let dump_status = dump
1075        .wait()
1076        .map_err(|e| internal(format!("waiting for pg_dump: {e}")))?;
1077    let restore_status = restore?;
1078    let mut failed = Vec::new();
1079    if !dump_status.success() {
1080        failed.push(format!("pg_dump failed ({dump_status})"));
1081    }
1082    if !restore_status.success() {
1083        failed.push(format!("pg_restore failed ({restore_status})"));
1084    }
1085    if failed.is_empty() {
1086        Ok(())
1087    } else {
1088        Err(internal(failed.join("; ")))
1089    }
1090}
1091
1092/// Runs a seed or migrate command (`what`) against the build server over TCP, in its own
1093/// process group.
1094#[allow(clippy::too_many_arguments)]
1095async fn run_command(
1096    install: &PgInstall,
1097    opts: &StartOptions,
1098    db: &str,
1099    command: &str,
1100    cwd: Option<&Path>,
1101    path: Option<&str>,
1102    groups: &Groups,
1103    what: &str,
1104) -> Result<()> {
1105    // initdb sets host auth to scram-sha-256, so TCP needs a password: a throwaway one, cleared afterwards.
1106    // log_min_error_statement=panic keeps a failing statement (with the password) out of the log.
1107    let password = random_hex(16);
1108    let sql = format!(
1109        "SET log_min_error_statement = panic; SET password_encryption = 'scram-sha-256'; \
1110         ALTER ROLE postgres PASSWORD '{password}'"
1111    );
1112    run_psql(install, opts, "postgres", sql).await?;
1113    let url = format!(
1114        "postgres://postgres:{password}@127.0.0.1:{}/{}",
1115        opts.port,
1116        url_encode(db)
1117    );
1118    let log = || OpenOptions::new().append(true).open(&opts.log_file);
1119    // An absolute shell, so a caller's PATH without /bin still runs it.
1120    let mut cmd = tokio::process::Command::new("/bin/sh");
1121    cmd.arg("-c")
1122        .arg(command)
1123        .env("DATABASE_URL", &url)
1124        .process_group(0)
1125        .stdin(Stdio::null())
1126        .stdout(log()?)
1127        .stderr(log()?);
1128    if let Some(dir) = cwd {
1129        cmd.current_dir(dir);
1130    }
1131    if let Some(p) = path {
1132        cmd.env("PATH", p);
1133    }
1134    let spawn_err = |e: std::io::Error| match cwd {
1135        Some(dir) => internal(format!(
1136            "could not run the {what} command in {}: {e}",
1137            dir.display()
1138        )),
1139        None => internal(format!("could not run the {what} command: {e}")),
1140    };
1141    let mut child = cmd.spawn().map_err(spawn_err)?;
1142    let group = child.id().map(|pid| GroupGuard::new(groups, pid as i32));
1143    let status = child.wait().await.map_err(spawn_err)?;
1144    if !status.success() {
1145        // Background jobs the seed left behind would otherwise outlive the build.
1146        if let Some(g) = &group {
1147            g.kill();
1148        }
1149        return Err(internal(format!("{what} command failed ({status})")));
1150    }
1151    drop(group);
1152    run_psql(
1153        install,
1154        opts,
1155        "postgres",
1156        "ALTER ROLE postgres PASSWORD NULL".into(),
1157    )
1158    .await?;
1159    Ok(())
1160}
1161
1162/// Steps 3-6: initdb, start, create database, restore or seed, extensions, stop.
1163// Named by port, not template name, to stay under the Unix socket path limit.
1164fn build_socket_dir(ctx: &Ctx, port: u16) -> PathBuf {
1165    ctx.home.sockets_dir().join(format!("build-{port}"))
1166}
1167
1168/// True for the names `build_socket_dir` makes; gc touches no other non-clone name in `sockets/`.
1169pub(crate) fn is_build_socket_name(name: &str) -> bool {
1170    name.strip_prefix("build-").is_some_and(|p| {
1171        !p.is_empty() && p.parse::<u16>().is_ok() && p.bytes().all(|b| b.is_ascii_digit())
1172    })
1173}
1174
1175/// A leftover folder `build` makes in `templates/`: `.<name>.building-<ts>` or `.<name>.old-<ts>`.
1176/// The string is the template's `folder_stem`.
1177#[derive(Debug, PartialEq, Eq)]
1178pub(crate) enum Leftover {
1179    Building(String),
1180    Old(String, i64),
1181}
1182
1183pub(crate) fn build_dir_name(name: &str, ts: i64) -> String {
1184    format!(".{}.building-{ts}", folder_stem(name))
1185}
1186
1187fn old_dir_name(name: &str, ts: i64) -> String {
1188    format!(".{}.old-{ts}", folder_stem(name))
1189}
1190
1191/// The build log of template `name`; a branch template's name has `/`-bearing parts, so the
1192/// file is named by its folder stem.
1193pub(crate) fn build_log_path(home: &Home, name: &str) -> PathBuf {
1194    home.logs_dir()
1195        .join(format!("template-{}.log", folder_stem(name)))
1196}
1197
1198/// The folder of generation `gen` of template `name`, in `templates/`.
1199fn gen_dir_name(name: &str, gen: u32) -> String {
1200    format!("{}@{gen}", folder_stem(name))
1201}
1202
1203/// The name of the template built for git branch `branch` of template `base`.
1204pub fn branch_template_name(base: &str, branch: &str) -> String {
1205    format!("{base}~{branch}")
1206}
1207
1208/// What stands for a branch template in folder names: `base`, `~`, the branch as a lowercase
1209/// ASCII slug (any other character becomes `-`, at most 40) and `-` plus 8 hex of its SHA-256.
1210pub fn branch_folder_slug(base: &str, branch: &str) -> String {
1211    let slug: String = branch
1212        .chars()
1213        .map(|c| match c.to_ascii_lowercase() {
1214            c @ ('a'..='z' | '0'..='9' | '-') => c,
1215            _ => '-',
1216        })
1217        .take(40)
1218        .collect();
1219    let hash: String = Sha256::digest(branch.as_bytes())
1220        .iter()
1221        .take(4)
1222        .map(|b| format!("{b:02x}"))
1223        .collect();
1224    format!("{base}~{slug}-{hash}")
1225}
1226
1227/// What stands for template `name` in folder and log names: the name, or a branch template's slug.
1228pub(crate) fn folder_stem(name: &str) -> String {
1229    match name.split_once('~') {
1230        Some((base, branch)) => branch_folder_slug(base, branch),
1231        None => name.to_string(),
1232    }
1233}
1234
1235/// True for the names `folder_stem` makes.
1236fn valid_stem(stem: &str) -> bool {
1237    match stem.split_once('~') {
1238        Some((base, slug)) => {
1239            validate_name("template", base).is_ok()
1240                && (10..=49).contains(&slug.len())
1241                && slug
1242                    .bytes()
1243                    .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
1244        }
1245        None => validate_name("template", stem).is_ok(),
1246    }
1247}
1248
1249/// A template name a read request may name: a plain name, or a branch template `<base>~<branch>`.
1250pub fn validate_template_ref(name: &str) -> Result<()> {
1251    match name.split_once('~') {
1252        Some((base, branch)) => {
1253            validate_name("template", base)?;
1254            check_branch_name(branch)
1255        }
1256        None => validate_name("template", name),
1257    }
1258}
1259
1260/// A git branch name `clone --branch` accepts.
1261fn check_branch_name(branch: &str) -> Result<()> {
1262    if branch.is_empty()
1263        || branch.len() > 255
1264        || branch.contains('~')
1265        || branch.chars().any(char::is_control)
1266    {
1267        return Err(invalid(format!("invalid branch name {branch:?}")));
1268    }
1269    Ok(())
1270}
1271
1272/// Parses the names `gen_dir_name` makes; anything else is `None`.
1273pub(crate) fn parse_gen_dir(file_name: &str) -> Option<(String, u32)> {
1274    let (name, gen) = file_name.split_once('@')?;
1275    valid_stem(name).then_some(())?;
1276    if gen.is_empty() || !gen.bytes().all(|b| b.is_ascii_digit()) {
1277        return None;
1278    }
1279    Some((name.into(), gen.parse().ok()?))
1280}
1281
1282/// Parses the names `build_dir_name` and `old_dir_name` make; anything else is `None`.
1283pub(crate) fn parse_leftover(file_name: &str) -> Option<Leftover> {
1284    let (name, suffix) = file_name.strip_prefix('.')?.split_once('.')?;
1285    valid_stem(name).then_some(())?;
1286    let ts = |s: &str| {
1287        s.bytes()
1288            .all(|b| b.is_ascii_digit())
1289            .then(|| s.parse::<i64>().ok())
1290            .flatten()
1291    };
1292    if let Some(t) = suffix.strip_prefix("building-") {
1293        ts(t).map(|_| Leftover::Building(name.into()))
1294    } else {
1295        ts(suffix.strip_prefix("old-")?).map(|t| Leftover::Old(name.into(), t))
1296    }
1297}
1298
1299async fn run_steps(
1300    ctx: &Ctx,
1301    install: &PgInstall,
1302    data_dir: &Path,
1303    opts: &mut StartOptions,
1304    database: &str,
1305    source: &TemplateSource,
1306    mask: Option<&Masking>,
1307) -> StepResult<Vec<String>> {
1308    let pg = if let TemplateSource::Basebackup { url } = source {
1309        basebackup_server(ctx, install, url, data_dir, opts, database).await?
1310    } else if let TemplateSource::Running { dataset } = source {
1311        let (b, ds, d) = (ctx.backend.clone(), dataset.clone(), data_dir.to_path_buf());
1312        blocking(move || copy_running(&*b, &ds, &d))
1313            .await
1314            .map_err(at(SNAPSHOT_STEP))?;
1315        standalone_server(ctx, install, data_dir, opts, database).await?
1316    } else {
1317        let (i, d) = (install.clone(), data_dir.to_path_buf());
1318        blocking(move || initdb(&i, &d))
1319            .await
1320            .map_err(at("initdb"))?;
1321        let pg = start_build_server(ctx, install, data_dir, opts).await?;
1322
1323        if database != "postgres" {
1324            let sql = format!("CREATE DATABASE {}", quote_ident(database));
1325            run_psql(install, opts, "postgres", sql)
1326                .await
1327                .map_err(at("create database"))?;
1328        }
1329        pg
1330    };
1331
1332    match source {
1333        // Already in the copy.
1334        TemplateSource::Basebackup { .. } | TemplateSource::Running { .. } => {}
1335        TemplateSource::Url { url } => {
1336            let (i, u, o, db, g) = (
1337                install.clone(),
1338                url.clone(),
1339                opts.clone(),
1340                database.to_string(),
1341                ctx.build_groups.clone(),
1342            );
1343            blocking(move || dump_restore(&i, &u, &o, &db, &g))
1344                .await
1345                .map_err(at("restore"))?;
1346        }
1347        TemplateSource::Seed { command, cwd, path } => {
1348            run_command(
1349                install,
1350                opts,
1351                database,
1352                command,
1353                cwd.as_deref(),
1354                path.as_deref(),
1355                &ctx.build_groups,
1356                "seed",
1357            )
1358            .await
1359            .map_err(at("seed"))?;
1360        }
1361        TemplateSource::Promoted { .. } => {
1362            return Err((
1363                "seed",
1364                invalid("a promoted template has no source to rebuild"),
1365            ));
1366        }
1367    }
1368    if let Some(m) = mask {
1369        mask_and_dump(ctx, install, data_dir, opts, database, m).await?;
1370    }
1371    extensions_and_stop(ctx, install, data_dir, opts, database, pg).await
1372}
1373
1374/// Step name used only for "the source has no such database"; `build_gen` turns that
1375/// `invalid_request` into a plain bad request instead of a failed build.
1376const MISSING_DATABASE_STEP: &str = "missing database";
1377/// The copy step of a running source; its refusals (`invalid_request`, `backend_unsupported`)
1378/// reach the caller as they are, like `MISSING_DATABASE_STEP`.
1379const SNAPSHOT_STEP: &str = "snapshot copy";
1380
1381/// The major version of the server at `url`, read with `psql` of any install.
1382fn source_major(install: &PgInstall, url: &str) -> Result<u32> {
1383    let split = split_password(url)?;
1384    let mut cmd = Command::new(install.bin("psql"));
1385    if let Some(pw) = &split.password {
1386        cmd.env("PGPASSWORD", pw);
1387    }
1388    let out = cmd
1389        .args(["-X", "-A", "-t", "-w", "-c", "show server_version_num"])
1390        .arg(&split.url)
1391        .env("PGCONNECT_TIMEOUT", "10")
1392        .stdin(Stdio::null())
1393        .output()
1394        .map_err(|e| internal(format!("could not run psql: {e}")))?;
1395    let secrets = source_secrets(&TemplateSource::Basebackup { url: url.into() });
1396    if !out.status.success() {
1397        let err = scrub(String::from_utf8_lossy(&out.stderr).trim(), &secrets);
1398        return Err(MitosisError::new(
1399            ErrorCode::TemplateBuildFailed,
1400            format!("could not read the version of the source server: {err}"),
1401        )
1402        .with_hint("check the URL and password, and that the server accepts this connection"));
1403    }
1404    let text = String::from_utf8_lossy(&out.stdout);
1405    text.trim()
1406        .parse::<u32>()
1407        .map(|n| n / 10_000)
1408        .map_err(|_| internal(format!("unexpected server_version_num {:?}", text.trim())))
1409}
1410
1411/// The install whose `pg_basebackup` matches the major of the source at `url`. Fails before any
1412/// copy when that major is not installed.
1413async fn basebackup_install(ctx: &Ctx, url: &str, pinned: Option<u32>) -> Result<PgInstall> {
1414    let probe = ctx.newest_install().await?;
1415    let u = url.to_string();
1416    let major = blocking(move || source_major(&probe, &u)).await?;
1417    if let Some(m) = pinned.filter(|m| *m != major) {
1418        return Err(invalid(format!(
1419            "the source runs Postgres {major}, so its physical copy is Postgres {major}, not {m}"
1420        ))
1421        .with_hint("drop --pg-major; to move to another major, upgrade the template"));
1422    }
1423    ctx.install_major(major).await.map_err(|e| {
1424        if e.code != ErrorCode::NoMatchingPostgres {
1425            return e;
1426        }
1427        MitosisError::new(
1428            ErrorCode::NoMatchingPostgres,
1429            format!("the source runs Postgres {major} and pg_basebackup needs the same major; no Postgres {major} found"),
1430        )
1431        .with_hint(format!(
1432            "{}, or: mitosis pg install {major}",
1433            crate::pg::binaries::install_hint(major)
1434        ))
1435    })
1436}
1437
1438/// True when `pg_basebackup`'s error says the source refused a replication connection.
1439fn refused_replication(stderr: &str) -> bool {
1440    let s = stderr.to_ascii_lowercase();
1441    s.contains("replication") || s.contains("walsender") || s.contains("wal sender")
1442}
1443
1444const REPLICATION_NOTE: &str =
1445    "mitosis: the source refused the physical copy. The role in the URL \
1446    needs the REPLICATION attribute (ALTER ROLE <role> REPLICATION), and the source's pg_hba.conf \
1447    needs a 'replication' line that allows it (host replication <role> <address> scram-sha-256).";
1448
1449/// `pg_basebackup -D dir -X stream -c fast` of the server at `url`, stderr to the build log. The
1450/// password goes in `PGPASSWORD`, never in argv. `dir` is missing or empty (a build folder is
1451/// made empty by the back end first).
1452fn run_basebackup(
1453    install: &PgInstall,
1454    url: &str,
1455    dir: &Path,
1456    log: &Path,
1457    groups: &Groups,
1458) -> Result<()> {
1459    let split = split_password(url)?;
1460    let mut cmd = Command::new(install.bin("pg_basebackup"));
1461    if let Some(pw) = &split.password {
1462        cmd.env("PGPASSWORD", pw);
1463    }
1464    let child = cmd
1465        .args(["-X", "stream", "-c", "fast", "-w", "-D"])
1466        .arg(dir)
1467        .arg("-d")
1468        .arg(&split.url)
1469        .env("PGCONNECT_TIMEOUT", "10")
1470        .process_group(0)
1471        .stdin(Stdio::null())
1472        .stdout(Stdio::null())
1473        .stderr(Stdio::piped())
1474        .spawn()
1475        .map_err(|e| internal(format!("could not run pg_basebackup: {e}")))?;
1476    let _group = GroupGuard::new(groups, child.id() as i32);
1477    let out = child
1478        .wait_with_output()
1479        .map_err(|e| internal(format!("waiting for pg_basebackup: {e}")))?;
1480    let stderr = String::from_utf8_lossy(&out.stderr);
1481    if !stderr.trim().is_empty() {
1482        append_log(log, stderr.trim());
1483    }
1484    if out.status.success() {
1485        return Ok(());
1486    }
1487    if refused_replication(&stderr) {
1488        append_log(log, REPLICATION_NOTE);
1489    }
1490    Err(internal(format!("pg_basebackup failed ({})", out.status)))
1491}
1492
1493/// The owner's login name of uid `uid`, else `uid <n>`.
1494fn user_name(uid: u32) -> String {
1495    Command::new("id")
1496        .args(["-nu", &uid.to_string()])
1497        .output()
1498        .ok()
1499        .filter(|o| o.status.success())
1500        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
1501        .filter(|n| !n.is_empty())
1502        .unwrap_or_else(|| format!("uid {uid}"))
1503}
1504
1505/// Refuses a running source's data directory `dir` that Mitosis cannot use: one another user owns
1506/// (Postgres will not start on it and only root could chown it), or a dataset root with no
1507/// `PG_VERSION`. Returns its Postgres major.
1508fn check_running_dir(dir: &Path, dataset: &str) -> Result<u32> {
1509    let md = std::fs::metadata(dir)
1510        .map_err(|e| invalid(format!("cannot read the source {}: {e}", dir.display())))?;
1511    let me = unsafe { libc::geteuid() };
1512    if md.uid() != me {
1513        return Err(invalid(format!(
1514            "the source data directory is owned by {}; Mitosis must run as that user",
1515            user_name(md.uid())
1516        )));
1517    }
1518    if !dir.join("PG_VERSION").is_file() {
1519        return Err(invalid(format!(
1520            "{} (the root of ZFS dataset {dataset}) is not a Postgres data directory: it has no PG_VERSION",
1521            dir.display()
1522        ))
1523        .with_hint("name the dataset whose mountpoint is the data directory itself"));
1524    }
1525    read_pg_version(dir)
1526}
1527
1528/// The install for a running source: the major of the data directory at `dataset`'s mountpoint.
1529/// Fails before any snapshot when that major is not installed.
1530async fn running_install(ctx: &Ctx, dataset: &str, pinned: Option<u32>) -> Result<PgInstall> {
1531    let (b, ds) = (ctx.backend.clone(), dataset.to_string());
1532    let major = blocking(move || {
1533        let dir = b.external_mountpoint(&ds)?;
1534        check_running_dir(&dir, &ds)
1535    })
1536    .await?;
1537    if let Some(m) = pinned.filter(|m| *m != major) {
1538        return Err(invalid(format!(
1539            "the source runs Postgres {major}, so its copy is Postgres {major}, not {m}"
1540        ))
1541        .with_hint("drop --pg-major; to move to another major, upgrade the template"));
1542    }
1543    ctx.install_major(major).await.map_err(|e| {
1544        if e.code != ErrorCode::NoMatchingPostgres {
1545            return e;
1546        }
1547        MitosisError::new(
1548            ErrorCode::NoMatchingPostgres,
1549            format!("the source runs Postgres {major}; no Postgres {major} found"),
1550        )
1551        .with_hint(format!(
1552            "{}, or: mitosis pg install {major}",
1553            crate::pg::binaries::install_hint(major)
1554        ))
1555    })
1556}
1557
1558/// Refuses a snapshot of a running source whose copy could not be made consistent or standalone:
1559/// another owner, WAL on another dataset (a snapshot is atomic within one dataset only), or a
1560/// replica.
1561fn check_running_snapshot(snap: &Path, dataset: &str) -> Result<()> {
1562    check_running_dir(snap, dataset)?;
1563    if std::fs::symlink_metadata(snap.join("pg_wal")).is_ok_and(|m| m.file_type().is_symlink()) {
1564        return Err(invalid(format!(
1565            "pg_wal of {dataset} is a symlink; pg_wal must be on the same dataset"
1566        )));
1567    }
1568    // A tablespace is a pg_tblspc symlink to a folder outside the snapshot; a copy of the link
1569    // would make every clone write into the source's own tablespace files.
1570    if let Ok(entries) = std::fs::read_dir(snap.join("pg_tblspc")) {
1571        for e in entries.flatten() {
1572            if e.file_type().is_ok_and(|t| t.is_symlink()) {
1573                return Err(invalid(format!(
1574                    "{dataset} has a tablespace outside its data directory (pg_tblspc/{}); tablespaces must be on the same dataset",
1575                    e.file_name().to_string_lossy()
1576                )));
1577            }
1578        }
1579    }
1580    for signal in ["standby.signal", "recovery.signal"] {
1581        if snap.join(signal).exists() {
1582            return Err(invalid(format!(
1583                "{dataset} is a replica ({signal}); replica sources come in 1.2"
1584            )));
1585        }
1586    }
1587    Ok(())
1588}
1589
1590/// Copies a snapshot of the source in `snap` into `data_dir` (made empty first when missing)
1591/// and removes the copied `postmaster.pid`. The source itself is never written.
1592fn copy_snapshot(snap: &Path, dataset: &str, data_dir: &Path) -> Result<()> {
1593    check_running_snapshot(snap, dataset)?;
1594    if !data_dir.exists() {
1595        // A masked build's staging folder; the build folder was made by the back end.
1596        std::fs::DirBuilder::new().mode(0o700).create(data_dir)?;
1597    }
1598    fill_copy(snap, data_dir)?;
1599    match std::fs::remove_file(data_dir.join("postmaster.pid")) {
1600        Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),
1601        _ => Ok(()),
1602    }
1603}
1604
1605/// The copy step of a running-source template: snapshots `dataset`, copies the snapshot into
1606/// `data_dir`, and destroys the snapshot right after, on every path, so nothing of Mitosis's
1607/// keeps the source's owner from changing or removing the dataset.
1608fn copy_running(backend: &dyn CloneBackend, dataset: &str, data_dir: &Path) -> Result<()> {
1609    let name = format!("mitosis-{}", random_hex(12));
1610    let snap = backend.snapshot_external(dataset, &name)?;
1611    let copied = copy_snapshot(&snap, dataset, data_dir);
1612    let destroyed = backend.destroy_external(dataset, &name);
1613    copied?;
1614    destroyed
1615}
1616
1617/// Ensures the `postgres` superuser, then starts the copied cluster. A `shared_preload_libraries`
1618/// entry this machine lacks is commented out once, with a warning in the build log.
1619async fn standalone_start(
1620    ctx: &Ctx,
1621    install: &PgInstall,
1622    data_dir: &Path,
1623    opts: &mut StartOptions,
1624) -> StepResult<RunningPg> {
1625    let mut retried = false;
1626    loop {
1627        let (i, d) = (install.clone(), data_dir.to_path_buf());
1628        let attempt = match blocking(move || ensure_postgres_superuser(&i, &d)).await {
1629            Ok(()) => start_build_server(ctx, install, data_dir, opts).await,
1630            Err(e) => Err(("standalone", e)),
1631        };
1632        let (step, e) = match attempt {
1633            Ok(pg) => return Ok(pg),
1634            Err(failed) => failed,
1635        };
1636        if retried || !is_missing_library(&e.message) {
1637            return Err((step, e));
1638        }
1639        let d = data_dir.to_path_buf();
1640        let off = blocking(move || disable_preload_libraries(&d))
1641            .await
1642            .map_err(at("standalone"))?;
1643        if off.is_empty() {
1644            return Err((step, e));
1645        }
1646        for line in off {
1647            append_log(
1648                &opts.log_file,
1649                &format!("mitosis: warning: the copy needs a library this Postgres lacks; disabled: {line}"),
1650            );
1651        }
1652        retried = true;
1653    }
1654}
1655
1656/// The copy step of a `pg_basebackup` template: copies the source into `data_dir`, makes it a
1657/// standalone cluster, starts it, and checks that `database` is in it. The server is left running.
1658async fn basebackup_server(
1659    ctx: &Ctx,
1660    install: &PgInstall,
1661    url: &str,
1662    data_dir: &Path,
1663    opts: &mut StartOptions,
1664    database: &str,
1665) -> StepResult<RunningPg> {
1666    let (i, u, d, l, g) = (
1667        install.clone(),
1668        url.to_string(),
1669        data_dir.to_path_buf(),
1670        opts.log_file.clone(),
1671        ctx.build_groups.clone(),
1672    );
1673    blocking(move || run_basebackup(&i, &u, &d, &l, &g))
1674        .await
1675        .map_err(at("basebackup"))?;
1676    standalone_server(ctx, install, data_dir, opts, database).await
1677}
1678
1679/// After the copy step of a physical copy (`pg_basebackup` or a running source's snapshot): makes
1680/// `data_dir` a standalone cluster (which also turns `archive_mode` off), starts it (crash
1681/// recovery replays the copied WAL), and checks that `database` is in it. The server is left running.
1682async fn standalone_server(
1683    ctx: &Ctx,
1684    install: &PgInstall,
1685    data_dir: &Path,
1686    opts: &mut StartOptions,
1687    database: &str,
1688) -> StepResult<RunningPg> {
1689    let d = data_dir.to_path_buf();
1690    let warnings = blocking(move || make_standalone(&d))
1691        .await
1692        .map_err(at("standalone"))?;
1693    for w in warnings {
1694        append_log(&opts.log_file, &format!("mitosis: warning: {w}"));
1695    }
1696    let pg = standalone_start(ctx, install, data_dir, opts).await?;
1697    // template1 always exists and accepts connections.
1698    let out = run_psql(
1699        install,
1700        opts,
1701        "template1",
1702        "SELECT datname FROM pg_database WHERE datallowconn AND NOT datistemplate ORDER BY 1"
1703            .into(),
1704    )
1705    .await
1706    .map_err(at("list databases"))?;
1707    let have: Vec<&str> = out
1708        .lines()
1709        .map(str::trim)
1710        .filter(|l| !l.is_empty())
1711        .collect();
1712    if !have.contains(&database) {
1713        return Err((
1714            MISSING_DATABASE_STEP,
1715            invalid(format!(
1716                "database {database} does not exist in the source (it has: {})",
1717                have.join(", ")
1718            ))
1719            .with_hint("name one of them with --database"),
1720        ));
1721    }
1722    Ok(pg)
1723}
1724
1725/// Starts the build server in `data_dir`, moving `opts` to another port when the picked one is taken.
1726async fn start_build_server(
1727    ctx: &Ctx,
1728    install: &PgInstall,
1729    data_dir: &Path,
1730    opts: &mut StartOptions,
1731) -> StepResult<RunningPg> {
1732    let mut tried = HashSet::new();
1733    Ok(loop {
1734        let pg = start(install, data_dir, opts).map_err(at("start"))?;
1735        match wait_ready(install, &pg, opts, ctx.config().ready_timeout).await {
1736            Ok(()) => break pg,
1737            // Another process can take the picked port before postgres binds it.
1738            Err(e) if e.code == ErrorCode::PortInUse && tried.len() + 1 < START_TRIES => {
1739                let (i, d, t) = (
1740                    install.clone(),
1741                    data_dir.to_path_buf(),
1742                    ctx.config().stop_timeout,
1743                );
1744                blocking(move || stop(&i, &d, Some(pg), t))
1745                    .await
1746                    .map_err(at("start"))?;
1747                let _ = std::fs::remove_dir(&opts.socket_dir);
1748                tried.insert(opts.port);
1749                opts.port = build_port(ctx, &tried).map_err(at("start"))?;
1750                opts.socket_dir = build_socket_dir(ctx, opts.port);
1751            }
1752            Err(e) => return Err(("start", e)),
1753        }
1754    })
1755}
1756
1757/// Lists the extensions of `database`, then stops the build server.
1758async fn extensions_and_stop(
1759    ctx: &Ctx,
1760    install: &PgInstall,
1761    data_dir: &Path,
1762    opts: &StartOptions,
1763    database: &str,
1764    pg: RunningPg,
1765) -> StepResult<Vec<String>> {
1766    let out = run_psql(
1767        install,
1768        opts,
1769        database,
1770        "SELECT extname FROM pg_extension ORDER BY extname".into(),
1771    )
1772    .await
1773    .map_err(at("extensions"))?;
1774    let extensions = out
1775        .lines()
1776        .map(str::trim)
1777        .filter(|l| !l.is_empty())
1778        .map(String::from)
1779        .collect();
1780
1781    let (i, d, t) = (
1782        install.clone(),
1783        data_dir.to_path_buf(),
1784        ctx.config().stop_timeout,
1785    );
1786    blocking(move || stop(&i, &d, Some(pg), t))
1787        .await
1788        .map_err(at("stop"))?;
1789    Ok(extensions)
1790}
1791
1792/// Stops any server in `dir` and deletes the folder. A server that would not stop keeps its
1793/// folder; gc deals with it later. The build folder (a generation to be) is removed by `backend`;
1794/// a staging folder (`None`) is plain scratch.
1795fn stop_and_remove(
1796    install: &PgInstall,
1797    dir: &Path,
1798    timeout: Duration,
1799    backend: Option<&dyn CloneBackend>,
1800) -> Result<()> {
1801    stop(install, dir, None, timeout)?;
1802    if let Some(b) = backend {
1803        return b.remove_dir(dir);
1804    }
1805    match std::fs::remove_dir_all(dir) {
1806        Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),
1807        _ => Ok(()),
1808    }
1809}
1810
1811/// Stops the build server, deletes the build folder, and turns `e` into `TemplateBuildFailed`.
1812async fn fail(
1813    ctx: &Ctx,
1814    install: &PgInstall,
1815    build_dir: &Path,
1816    log: &Path,
1817    secrets: &[String],
1818    step: &str,
1819    e: MitosisError,
1820) -> MitosisError {
1821    append_log(
1822        log,
1823        &scrub(&format!("mitosis: {step} failed: {e}"), secrets),
1824    );
1825    // Read the tail before stopping, so shutdown lines cannot push the cause out of it.
1826    let tail = scrub(&log_tail(log, 20), secrets);
1827    let (i, d, t, b) = (
1828        install.clone(),
1829        build_dir.to_path_buf(),
1830        ctx.config().stop_timeout,
1831        ctx.backend.clone(),
1832    );
1833    let cleanup = blocking(move || stop_and_remove(&i, &d, t, Some(&*b))).await;
1834    if let Err(ce) = cleanup {
1835        append_log(
1836            log,
1837            &scrub(
1838                &format!("mitosis: cleanup of {} failed: {ce}", build_dir.display()),
1839                secrets,
1840            ),
1841        );
1842    }
1843    MitosisError::new(
1844        ErrorCode::TemplateBuildFailed,
1845        format!("template build failed at {step}\n{tail}"),
1846    )
1847    .with_hint(format!("full log: {}", log.display()))
1848}
1849
1850/// A manual build of `args.name`: takes its build slot first.
1851async fn build(ctx: &Ctx, args: TemplateInitArgs) -> Result<TemplateInfo> {
1852    let _guard = BuildGuard::take(ctx, &args.name)?;
1853    build_held(ctx, args, true).await
1854}
1855
1856/// `build` for a caller that holds the build slot. Only a `manual` build clears `paused`.
1857async fn build_held(ctx: &Ctx, args: TemplateInitArgs, manual: bool) -> Result<TemplateInfo> {
1858    let name = args.name.clone();
1859    let database = database_for(&args)?;
1860    let install = match (&args.source, args.pg_major) {
1861        // A physical copy keeps the major of the server it is copied from.
1862        (TemplateSource::Basebackup { url }, pinned) => {
1863            basebackup_install(ctx, url, pinned).await?
1864        }
1865        (TemplateSource::Running { dataset }, pinned) => {
1866            running_install(ctx, dataset, pinned).await?
1867        }
1868        (_, Some(major)) => ctx.install_major(major).await?,
1869        (_, None) => ctx.newest_install().await?,
1870    };
1871    let migrations_hash = match args.migrations.as_deref() {
1872        Some(m) if m.is_dir() => Some(hash_dir(m)?),
1873        _ => None,
1874    };
1875    // The masking file this build uses: the one given now, else the recorded one.
1876    let mask_path = match (&args.settings.mask, args.settings.mask_off) {
1877        (_, true) => None,
1878        (Some(p), false) => Some(p.clone()),
1879        (None, false) => ctx.store().get_template(&name)?.and_then(|t| t.mask_path),
1880    };
1881    let mask_file = match mask_path.as_deref() {
1882        Some(p) => {
1883            let path = p.to_path_buf();
1884            Some(blocking(move || MaskFile::load_hashed(&path)).await?)
1885        }
1886        None => None,
1887    };
1888    // The commit expects the pointer read here; a new template starts at 0 (no generation).
1889    let (created, expected, gen, masking) = {
1890        let store = ctx.store();
1891        let (created, expected) = match store.get_template(&name)? {
1892            Some(t) => (false, t.current_gen),
1893            None => {
1894                store.insert_template(&TemplateRow::new(
1895                    &name,
1896                    &database,
1897                    args.source.clone(),
1898                    args.migrations.clone(),
1899                    args.max_age_secs
1900                        .unwrap_or(ctx.config().default_max_age_secs),
1901                ))?;
1902                (true, 0)
1903            }
1904        };
1905        let masking = match mask_file {
1906            Some((file, hash)) => Some(Masking {
1907                file,
1908                hash,
1909                salt: store.ensure_mask_salt(&name, &random_hex(32))?,
1910            }),
1911            None => None,
1912        };
1913        (created, expected, store.alloc_gen(&name)?, masking)
1914    };
1915    let built = build_gen(
1916        ctx,
1917        &install,
1918        &args,
1919        &database,
1920        migrations_hash,
1921        masking.as_ref(),
1922        gen,
1923        expected,
1924    )
1925    .await;
1926    if let Err(e) = &built {
1927        events::emit_template_failed(ctx, &name, gen, "build", e);
1928    }
1929    let store = ctx.store();
1930    if let Err(e) = built {
1931        // A failed first build leaves no template behind.
1932        if created && matches!(store.list_generations(&name), Ok(g) if g.is_empty()) {
1933            let _ = store.delete_template(&name);
1934        }
1935        return Err(e);
1936    }
1937    // Re-read under the store lock; only what this build owns is written. `template set` may
1938    // have changed any setting meanwhile; the max age changes here only when the caller gave one.
1939    let mut t = store.get_template(&name)?.ok_or_else(|| not_found(&name))?;
1940    t.database = database;
1941    t.source = args.source;
1942    if t.migrations != args.migrations {
1943        clear_repo_paths(&mut t);
1944    }
1945    t.migrations = args.migrations;
1946    if let Some(m) = args.max_age_secs {
1947        t.max_age_secs = m;
1948    }
1949    apply_settings(&mut t, None, &args.settings)?;
1950    if manual {
1951        resume_if_current(&mut t, gen);
1952    }
1953    store.update_template_settings(&t)?;
1954    drop(store);
1955    template_info(ctx, &name)
1956}
1957
1958/// A successful build that is still current clears `paused` and `last_error`. One that a
1959/// rollback overtook leaves them, so the rollback sticks.
1960pub(crate) fn resume_if_current(t: &mut TemplateRow, gen: u32) {
1961    if t.current_gen == gen {
1962        t.paused = false;
1963        t.last_error = None;
1964    }
1965}
1966
1967/// Copies the current generation of `name`, runs only its migrate command on the copy and makes
1968/// the result a `Layered` generation. `path`, when given, replaces the command's recorded `PATH`.
1969/// Refused while the template's recorded checkout is on another branch than its `base_branch`.
1970pub async fn layered(ctx: &Ctx, name: &str, path: Option<String>) -> Result<TemplateInfo> {
1971    validate_name("template", name)?;
1972    if let Some((base_branch, current)) = off_base_branch(ctx, name).await? {
1973        return Err(invalid(format!(
1974            "the checkout of template {name} is on {}, not on its base branch {base_branch}",
1975            branch_label(&current)
1976        ))
1977        .with_hint(format!(
1978            "for this branch use: mitosis clone --branch; to refresh {name}: git switch {base_branch}"
1979        )));
1980    }
1981    let _guard = BuildGuard::take(ctx, name)?;
1982    layered_held(ctx, name, path, true).await
1983}
1984
1985/// `branch X` for a branch name, `a detached HEAD` for the empty one.
1986fn branch_label(branch: &str) -> String {
1987    if branch.is_empty() {
1988        "a detached HEAD".into()
1989    } else {
1990        format!("branch {branch}")
1991    }
1992}
1993
1994/// `(base_branch, checked out branch)` when `name` has a `base_branch` and its migrate folder's
1995/// checkout is on another branch (`""` for a detached HEAD); `None` otherwise, also when git
1996/// cannot tell.
1997async fn off_base_branch(ctx: &Ctx, name: &str) -> Result<Option<(String, String)>> {
1998    let Some(t) = ctx.store().get_template(name)? else {
1999        return Ok(None);
2000    };
2001    let (Some(base_branch), Some(m)) = (t.base_branch, t.migrate) else {
2002        return Ok(None);
2003    };
2004    let current = blocking(move || Ok(git(&m.cwd, &["branch", "--show-current"]))).await?;
2005    Ok(current
2006        .filter(|c| *c != base_branch)
2007        .map(|c| (base_branch, c)))
2008}
2009
2010/// `template.skipped` for a layered build of `name` skipped because its checkout is on `current`;
2011/// not again while the newest event of `name` already says so.
2012fn emit_skipped(ctx: &Ctx, name: &str, base_branch: &str, current: &str) {
2013    let filter = EventFilter {
2014        template: Some(name.to_string()),
2015        limit: Some(1),
2016        ..Default::default()
2017    };
2018    let last = ctx.store().list_events(&filter).unwrap_or_default();
2019    if last.last().is_some_and(|e| {
2020        e.kind == events::TEMPLATE_SKIPPED
2021            && e.detail.get("branch").and_then(|b| b.as_str()) == Some(current)
2022    }) {
2023        return;
2024    }
2025    events::emit(
2026        ctx,
2027        events::TEMPLATE_SKIPPED,
2028        None,
2029        Some(name),
2030        serde_json::json!({
2031            "op": "layered",
2032            "reason": "checkout on another branch",
2033            "branch": current,
2034            "base_branch": base_branch,
2035        }),
2036    );
2037}
2038
2039/// `layered` for a caller that holds the build slot. Only a `manual` build clears `paused`.
2040async fn layered_held(
2041    ctx: &Ctx,
2042    name: &str,
2043    path: Option<String>,
2044    manual: bool,
2045) -> Result<TemplateInfo> {
2046    let (t, cur) = ctx.store().current(name)?.ok_or_else(|| not_found(name))?;
2047    let Some(mut migrate) = t.migrate.clone() else {
2048        return Err(
2049            invalid(format!("template {name} has no migrate command")).with_hint(format!(
2050                "set one with: mitosis template set {name} --migrate \"…\""
2051            )),
2052        );
2053    };
2054    migrate.path = path.or(migrate.path);
2055    let gen = build_layer(
2056        ctx,
2057        Layer {
2058            target: name,
2059            src: &t,
2060            from: &cur,
2061            migrate,
2062            migrations: t.migrations.clone(),
2063            expected: cur.gen,
2064        },
2065    )
2066    .await?;
2067    if manual {
2068        let store = ctx.store();
2069        let mut t = store.get_template(name)?.ok_or_else(|| not_found(name))?;
2070        resume_if_current(&mut t, gen);
2071        store.update_template_settings(&t)?;
2072    }
2073    template_info(ctx, name)
2074}
2075
2076/// A layered build: generation `from` of template `src` copied, `migrate` run on the copy (and
2077/// `src`'s masking applied again), committed as the next generation of `target` when its current
2078/// pointer is still `expected`. `target` is `src` itself, or a branch template of it.
2079struct Layer<'a> {
2080    target: &'a str,
2081    src: &'a TemplateRow,
2082    from: &'a GenerationRow,
2083    migrate: MigrateCmd,
2084    /// Hashed into the new generation, for freshness checks.
2085    migrations: Option<PathBuf>,
2086    expected: u32,
2087}
2088
2089/// Runs `l`; returns the new generation's number. The caller holds the build slot of `l.target`.
2090async fn build_layer(ctx: &Ctx, l: Layer<'_>) -> Result<u32> {
2091    let (name, t, cur) = (l.target, l.src, l.from);
2092    // Masking in place covers only what the migration adds: the copied pages must already be
2093    // masked with this very file, or raw values of newly masked columns would stay in them.
2094    let masking = match t.mask_path.as_deref() {
2095        Some(p) => {
2096            let path = p.to_path_buf();
2097            let (file, hash) = blocking(move || MaskFile::load_hashed(&path)).await?;
2098            if cur.mask_hash.as_deref() != Some(hash.as_str()) {
2099                return Err(invalid(format!(
2100                    "the current generation of template {} was not built with the masking \
2101                     file {} as it is now",
2102                    t.name,
2103                    p.display()
2104                ))
2105                .with_hint(format!(
2106                    "run a full build first: mitosis template refresh {}",
2107                    t.name
2108                )));
2109            }
2110            // The source's salt, so a branch template masks values as its base does.
2111            Some(Masking {
2112                file,
2113                hash,
2114                salt: ctx.store().ensure_mask_salt(&t.name, &random_hex(32))?,
2115            })
2116        }
2117        None => None,
2118    };
2119    let install = ctx.install_major(cur.pg_major).await?;
2120    let migrations_hash = match l.migrations.clone() {
2121        Some(m) => blocking(move || m.is_dir().then(|| hash_dir(&m)).transpose()).await?,
2122        None => None,
2123    };
2124    let gen = ctx.store().alloc_gen(name)?;
2125
2126    let mut secrets = source_secrets(&t.source);
2127    secrets.extend(masking.as_ref().map(|m| m.salt.clone()));
2128    let ts = now();
2129    let side = ctx.home.templates_dir().join(build_dir_name(name, ts));
2130    let mut opts = build_start_options(ctx, name)?;
2131    let log = opts.log_file.clone();
2132    let result = layered_steps(
2133        ctx,
2134        &install,
2135        &cur.dir,
2136        &side,
2137        &mut opts,
2138        &t.database,
2139        &l.migrate,
2140        masking.as_ref(),
2141    )
2142    .await;
2143    let result = match result {
2144        Ok(ext) => Ok(ext),
2145        Err((step, e)) => Err(fail(ctx, &install, &side, &log, &secrets, step, e).await),
2146    };
2147    if let Err(e) = &result {
2148        events::emit_template_failed(ctx, name, gen, "layered", e);
2149    }
2150    let _ = std::fs::remove_dir(&opts.socket_dir); // empty once the server is stopped
2151    let row = GenerationRow {
2152        template: name.to_string(),
2153        gen,
2154        kind: GenKind::Layered,
2155        dir: side,
2156        pg_major: 0,
2157        bin_dir: install.bin_dir.clone(),
2158        extensions: result?,
2159        migrations_hash,
2160        built_at: ts,
2161        size_bytes: 0,
2162        from_clone: None,
2163        from_gen: Some(cur.gen),
2164        mask_hash: masking.map(|m| m.hash),
2165    };
2166    if let Err(e) = commit_built(ctx, &install, &log, &secrets, row, l.expected).await {
2167        events::emit_template_failed(ctx, name, gen, "layered", &e);
2168        return Err(e);
2169    }
2170    Ok(gen)
2171}
2172
2173/// Copy of generation folder `from` into `dir`, started privately, migrated, masked again when
2174/// `mask` is set, stopped.
2175#[allow(clippy::too_many_arguments)]
2176async fn layered_steps(
2177    ctx: &Ctx,
2178    install: &PgInstall,
2179    from: &Path,
2180    dir: &Path,
2181    opts: &mut StartOptions,
2182    database: &str,
2183    migrate: &MigrateCmd,
2184    mask: Option<&Masking>,
2185) -> StepResult<Vec<String>> {
2186    {
2187        // A commit or prune moves generation folders only under the write guard.
2188        let _swap = ctx.template_swap.read().await;
2189        let (b, f, d) = (ctx.backend.clone(), from.to_path_buf(), dir.to_path_buf());
2190        blocking(move || b.copy_dir(&f, &d))
2191            .await
2192            .map_err(at("copy"))?;
2193    }
2194    let pg = start_build_server(ctx, install, dir, opts).await?;
2195    run_command(
2196        install,
2197        opts,
2198        database,
2199        &migrate.command,
2200        Some(&migrate.cwd),
2201        migrate.path.as_deref(),
2202        &ctx.build_groups,
2203        "migrate",
2204    )
2205    .await
2206    .map_err(at("migrate"))?;
2207    if let Some(m) = mask {
2208        apply_mask(install, opts, database, m, false)
2209            .await
2210            .map_err(at("mask"))?;
2211    }
2212    extensions_and_stop(ctx, install, dir, opts, database, pg).await
2213}
2214
2215/// What `clone --branch` gets: the branch template to clone and how its freshness was handled.
2216#[derive(Debug)]
2217pub struct BranchUse {
2218    pub name: String,
2219    /// It was rebuilt before the clone.
2220    pub rebuilt: bool,
2221    /// It is out of date and served as it is (the base has `on_stale = refresh`).
2222    pub stale: bool,
2223    /// A rebuild of it runs in the background.
2224    pub refreshing: bool,
2225    /// For the clone's response.
2226    pub warnings: Vec<String>,
2227}
2228
2229/// How long `clone --branch` waits for another create's build of the same branch template.
2230const BRANCH_BUILD_WAIT: Duration = Duration::from_secs(30 * 60);
2231
2232/// Where a branch build runs: the caller's checkout joined with the base's repo-relative paths.
2233struct Checkout {
2234    cwd: PathBuf,
2235    migrations: Option<PathBuf>,
2236}
2237
2238/// The branch template of `base` for the caller's checkout `b`: created on first use, and rebuilt
2239/// first when the base has another current generation than the one it was layered on, or the
2240/// checkout's migrations differ from the ones it was built with. With the base's
2241/// `on_stale = refresh`, an existing generation is served and rebuilt in the background instead.
2242pub async fn ensure_branch(ctx: &Arc<Ctx>, base: &str, b: &BranchArgs) -> Result<BranchUse> {
2243    validate_name("template", base)?;
2244    check_branch_name(&b.name)?;
2245    if !b.repo_root.is_absolute() {
2246        return Err(invalid(format!(
2247            "repository root {} is not an absolute path",
2248            b.repo_root.display()
2249        )));
2250    }
2251    let name = branch_template_name(base, &b.name);
2252    let mut warnings = Vec::new();
2253    loop {
2254        let (row, base_row, base_cur, checkout, warning) = branch_row(ctx, base, &name, b).await?;
2255        warnings.extend(warning);
2256        let used = |rebuilt, stale, refreshing, warnings| BranchUse {
2257            name: name.clone(),
2258            rebuilt,
2259            stale,
2260            refreshing,
2261            warnings,
2262        };
2263        let cur = ctx.store().current(&name)?.map(|(_, g)| g);
2264        let m = checkout.migrations;
2265        let hash =
2266            blocking(move || m.filter(|d| d.is_dir()).map(|d| hash_dir(&d)).transpose()).await?;
2267        let fresh = cur
2268            .as_ref()
2269            .is_some_and(|g| row.base_gen == Some(base_cur.gen) && g.migrations_hash == hash);
2270        if fresh {
2271            return Ok(used(false, false, false, warnings));
2272        }
2273        if cur.is_some() && base_row.on_stale == OnStale::Refresh {
2274            // A failed background build waits out the same back-off as a template's own.
2275            let start = crate::scheduler::may_retry(&row, now()) && claim_build(ctx, &name);
2276            if start {
2277                let (ctx, name, base, b) = (ctx.clone(), name.clone(), base.to_string(), b.clone());
2278                tokio::spawn(async move { background_branch(&ctx, &base, &name, &b).await });
2279            } else if let Some(e) = row
2280                .last_error
2281                .as_deref()
2282                .filter(|_| !is_building(ctx, &name))
2283            {
2284                warnings.push(format!(
2285                    "the last background rebuild of template {name} failed: {e}"
2286                ));
2287            }
2288            let refreshing = start || is_building(ctx, &name);
2289            return Ok(used(false, true, refreshing, warnings));
2290        }
2291        match BuildGuard::take(ctx, &name) {
2292            Ok(_guard) => {
2293                build_branch(ctx, base, &name, b).await?;
2294                return Ok(used(true, false, false, warnings));
2295            }
2296            // Another clone is building it: wait for that build, then look again.
2297            Err(_) => {
2298                let deadline = std::time::Instant::now() + BRANCH_BUILD_WAIT;
2299                while is_building(ctx, &name) {
2300                    if std::time::Instant::now() >= deadline {
2301                        let log = ctx
2302                            .home
2303                            .logs_dir()
2304                            .join(format!("template-{}.log", folder_stem(&name)));
2305                        return Err(invalid(format!(
2306                            "template {name} is still being built after {} minutes",
2307                            BRANCH_BUILD_WAIT.as_secs() / 60
2308                        ))
2309                        .with_hint(format!(
2310                            "try again later; the build log is {}",
2311                            log.display()
2312                        )));
2313                    }
2314                    tokio::time::sleep(Duration::from_millis(200)).await;
2315                }
2316            }
2317        }
2318    }
2319}
2320
2321/// The background rebuild `ensure_branch` started; the caller claimed the build slot of `name`.
2322/// Skipped when the caller's checkout is no longer on the branch. The outcome is stored in
2323/// `last_attempt_at` / `last_error`, which `may_retry` reads.
2324async fn background_branch(ctx: &Ctx, base: &str, name: &str, b: &BranchArgs) {
2325    // Adopts the slot claimed by the caller.
2326    let _guard = BuildGuard {
2327        ctx,
2328        name: name.to_string(),
2329    };
2330    let root = b.repo_root.clone();
2331    let current = blocking(move || Ok(git(&root, &["branch", "--show-current"])))
2332        .await
2333        .ok()
2334        .flatten();
2335    if current.as_deref() != Some(b.name.as_str()) {
2336        let current = current.unwrap_or_default();
2337        crate::lifecycle::log(
2338            ctx,
2339            &format!(
2340                "background build of template {name} skipped: the checkout in {} is on {}",
2341                b.repo_root.display(),
2342                branch_label(&current)
2343            ),
2344        );
2345        events::emit(
2346            ctx,
2347            events::TEMPLATE_SKIPPED,
2348            None,
2349            Some(name),
2350            serde_json::json!({
2351                "op": "branch",
2352                "reason": "checkout on another branch",
2353                "branch": current,
2354                "wanted": b.name,
2355            }),
2356        );
2357        return;
2358    }
2359    let started = now();
2360    let res = build_branch(ctx, base, name, b).await;
2361    let store = ctx.store();
2362    let Ok(Some(mut t)) = store.get_template(name) else {
2363        return;
2364    };
2365    t.last_attempt_at = Some(started);
2366    t.last_error = res.as_ref().err().map(|e| {
2367        let first = e.message.lines().next().unwrap_or_default();
2368        let msg = match &e.hint {
2369            Some(h) => format!("{first} ({h})"),
2370            None => first.to_string(),
2371        };
2372        scrub(&msg, &source_secrets(&t.source))
2373    });
2374    let saved = store.update_template_settings(&t);
2375    drop(store);
2376    if let Some(e) = &t.last_error {
2377        crate::lifecycle::log(
2378            ctx,
2379            &format!("background build of template {name} failed: {e}"),
2380        );
2381    }
2382    if let Err(e) = saved {
2383        crate::lifecycle::log(ctx, &format!("template {name}: store build result: {e}"));
2384    }
2385}
2386
2387/// What `branch_row` returns: the branch row, the base row and its current generation, where the
2388/// build runs, and a warning for the clone's response.
2389type BranchRow = (
2390    TemplateRow,
2391    TemplateRow,
2392    GenerationRow,
2393    Checkout,
2394    Option<String>,
2395);
2396
2397/// Reads the base and creates the branch template `name` when it has no row yet; marks it used.
2398/// Fails when the base has no migrate command or its migrate folder is not in a git work tree.
2399async fn branch_row(ctx: &Ctx, base: &str, name: &str, b: &BranchArgs) -> Result<BranchRow> {
2400    let (mut base_row, base_cur) = ctx.store().current(base)?.ok_or_else(|| not_found(base))?;
2401    let mut warning = None;
2402    let Some(migrate) = base_row.migrate.clone() else {
2403        return Err(invalid(format!(
2404            "template {base} has no migrate command, so it has no branch templates"
2405        ))
2406        .with_hint(format!(
2407            "set one from your git checkout: mitosis template set {base} --migrate \"…\""
2408        )));
2409    };
2410    if base_row.repo_rel_cwd.is_none() {
2411        // `template init` records the paths; a migrate command set later is looked up here.
2412        let (cwd, mig) = (migrate.cwd.clone(), base_row.migrations.clone());
2413        let paths = blocking(move || Ok(git_paths(Some(&cwd), mig.as_deref()))).await?;
2414        if paths.1.is_none() {
2415            return Err(invalid(format!(
2416                "the migrate command of template {base} runs in {}, which is not in a git work tree",
2417                migrate.cwd.display()
2418            ))
2419            .with_hint(format!(
2420                "--branch needs it inside your git checkout: mitosis template set {base} --migrate \"…\" from there"
2421            )));
2422        }
2423        // Only the paths: the branch checked out now is usually the caller's, not the base's.
2424        let store = ctx.store();
2425        if let Some(mut t) = store.get_template(base)? {
2426            (t.repo_rel_cwd, t.repo_rel_migrations) = (paths.1, paths.2);
2427            store.update_template_settings(&t)?;
2428            if t.base_branch.is_none() {
2429                warning = Some(format!(
2430                    "template {base} does not know its own branch; run mitosis template set \
2431                     {base} --base-branch <b> so it stops following other branches"
2432                ));
2433            }
2434            base_row = t;
2435        }
2436    }
2437    let checkout = Checkout {
2438        cwd: b
2439            .repo_root
2440            .join(base_row.repo_rel_cwd.as_deref().unwrap_or(Path::new(""))),
2441        migrations: base_row
2442            .repo_rel_migrations
2443            .as_ref()
2444            .map(|r| b.repo_root.join(r)),
2445    };
2446    if !checkout.cwd.is_dir() {
2447        return Err(invalid(format!(
2448            "{} does not exist in this checkout",
2449            checkout.cwd.display()
2450        ))
2451        .with_hint(format!(
2452            "run it in a checkout of the repository template {base} was made from"
2453        )));
2454    }
2455    let store = ctx.store();
2456    if store.get_template(name)?.is_none() {
2457        let mut t = TemplateRow::new(
2458            name,
2459            &base_row.database,
2460            base_row.source.clone(),
2461            checkout.migrations.clone(),
2462            base_row.max_age_secs,
2463        );
2464        (t.base, t.branch, t.keep) = (Some(base.into()), Some(b.name.clone()), base_row.keep);
2465        store.insert_template(&t)?;
2466    }
2467    let mut row = store.get_template(name)?.ok_or_else(|| not_found(name))?;
2468    (row.last_used, row.max_age_secs) = (Some(now()), base_row.max_age_secs);
2469    store.update_template_settings(&row)?;
2470    Ok((row, base_row, base_cur, checkout, warning))
2471}
2472
2473/// Layers a new generation of branch template `name` on the base's current generation, running the
2474/// base's migrate command in the caller's checkout. A failed first build leaves no template. The
2475/// caller holds the build slot of `name`.
2476async fn build_branch(ctx: &Ctx, base: &str, name: &str, b: &BranchArgs) -> Result<()> {
2477    let (row, base_row, base_cur, checkout, _) = branch_row(ctx, base, name, b).await?;
2478    let Some(mut migrate) = base_row.migrate.clone() else {
2479        return Err(not_found(base));
2480    };
2481    migrate.cwd = checkout.cwd;
2482    migrate.path = b.path.clone().or(migrate.path);
2483    let built = build_layer(
2484        ctx,
2485        Layer {
2486            target: name,
2487            src: &base_row,
2488            from: &base_cur,
2489            migrate,
2490            migrations: checkout.migrations.clone(),
2491            expected: row.current_gen,
2492        },
2493    )
2494    .await;
2495    let store = ctx.store();
2496    let Some(mut t) = store.get_template(name)? else {
2497        return built.map(drop);
2498    };
2499    match built {
2500        Ok(gen) => {
2501            if t.current_gen == gen {
2502                (t.base_gen, t.last_error) = (Some(base_cur.gen), None);
2503            }
2504            t.migrations = checkout.migrations;
2505            store.update_template_settings(&t)
2506        }
2507        Err(e) => {
2508            if matches!(store.list_generations(name), Ok(g) if g.is_empty()) {
2509                let _ = store.delete_template(name);
2510            }
2511            Err(e)
2512        }
2513    }
2514}
2515
2516/// A free port for a build server, skipping `skip`. Reserved ports too: a named clone's stable URL
2517/// must not lose its port to a build server.
2518fn build_port(ctx: &Ctx, skip: &HashSet<u16>) -> Result<u16> {
2519    let (leased, reserved) = {
2520        let store = ctx.store();
2521        (store.leased_ports()?, store.reserved_ports()?)
2522    };
2523    pick_port(ctx.config().port_range, &leased, skip, &reserved)
2524}
2525
2526/// Options for a private build server of `name` on a free port. Truncates the build log, so an
2527/// error tail belongs to this build.
2528fn build_start_options(ctx: &Ctx, name: &str) -> Result<StartOptions> {
2529    let log = build_log_path(&ctx.home, name);
2530    std::fs::File::create(&log)?;
2531    let port = build_port(ctx, &HashSet::new())?;
2532    Ok(StartOptions {
2533        port,
2534        socket_dir: build_socket_dir(ctx, port),
2535        listen_tcp: true,
2536        durable: false,
2537        log_file: log,
2538    })
2539}
2540
2541/// Builds generation `gen` in a side folder and commits it as a `Full` generation; through a
2542/// staging cluster when `masking` is set.
2543#[allow(clippy::too_many_arguments)]
2544async fn build_gen(
2545    ctx: &Ctx,
2546    install: &PgInstall,
2547    args: &TemplateInitArgs,
2548    database: &str,
2549    migrations_hash: Option<String>,
2550    masking: Option<&Masking>,
2551    gen: u32,
2552    expected: u32,
2553) -> Result<()> {
2554    let name = &args.name;
2555    let mut secrets = source_secrets(&args.source);
2556    secrets.extend(masking.map(|m| m.salt.clone()));
2557    let ts = now();
2558    let build_dir = ctx.home.templates_dir().join(build_dir_name(name, ts));
2559    let mut opts = build_start_options(ctx, name)?;
2560    let log = opts.log_file.clone();
2561    let staging = masking.map(|_| Staging::new(ctx, install, name, &build_dir));
2562
2563    // The build folder is made by the back end (a dataset on ZFS); initdb and pg_basebackup
2564    // fill an existing empty folder. A staging folder is plain scratch that initdb makes.
2565    let (b, d) = (ctx.backend.clone(), build_dir.clone());
2566    let created = blocking(move || b.create_dir(&d))
2567        .await
2568        .map_err(at("create"));
2569    let result = match (created, masking, &staging) {
2570        (Err(e), _, _) => Err(e),
2571        (Ok(()), None, _) | (Ok(()), _, None) => {
2572            run_steps(
2573                ctx,
2574                install,
2575                &build_dir,
2576                &mut opts,
2577                database,
2578                &args.source,
2579                None,
2580            )
2581            .await
2582        }
2583        (Ok(()), Some(m), Some(st)) => {
2584            let source = &args.source;
2585            masked_steps(ctx, install, st, &build_dir, &mut opts, database, source, m).await
2586        }
2587    };
2588    let result = match result {
2589        Ok(ext) => Ok(ext),
2590        Err((step, e)) => {
2591            // The caller named a database the source does not have: a bad request, not a failed build.
2592            let bad_request = (step == MISSING_DATABASE_STEP
2593                && e.code == ErrorCode::InvalidRequest)
2594                || (step == SNAPSHOT_STEP
2595                    && matches!(
2596                        e.code,
2597                        ErrorCode::InvalidRequest | ErrorCode::BackendUnsupported
2598                    ));
2599            let failed = fail(ctx, install, &build_dir, &log, &secrets, step, e.clone()).await;
2600            Err(if bad_request { e } else { failed })
2601        }
2602    };
2603    // Removed only now, so `fail` read the log tail before the staging server's shutdown lines.
2604    if let Some(st) = staging {
2605        st.remove(&log).await;
2606    }
2607    let _ = std::fs::remove_dir(&opts.socket_dir); // empty once the server is stopped
2608    let row = GenerationRow {
2609        template: name.clone(),
2610        gen,
2611        kind: GenKind::Full,
2612        dir: build_dir,
2613        pg_major: 0,
2614        bin_dir: install.bin_dir.clone(),
2615        extensions: result?,
2616        migrations_hash,
2617        built_at: ts,
2618        size_bytes: 0,
2619        from_clone: None,
2620        from_gen: None,
2621        mask_hash: masking.map(|m| m.hash.clone()),
2622    };
2623    commit_built(ctx, install, &log, &secrets, row, expected).await
2624}
2625
2626/// What a masked build needs: the masking file, its hash, and the template's salt.
2627struct Masking {
2628    file: MaskFile,
2629    hash: String,
2630    salt: String,
2631}
2632
2633/// Every column of every user table, as JSON for `ColumnInfo`.
2634const COLUMNS_SQL: &str = "SELECT coalesce(json_agg(json_build_object('schema', n.nspname, \
2635    'table', c.relname, 'column', a.attname, 'data_type', format_type(a.atttypid, a.atttypmod), \
2636    'not_null', a.attnotnull)), '[]') \
2637    FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid JOIN pg_namespace n ON n.oid = c.relnamespace \
2638    WHERE c.relkind IN ('r', 'p') AND a.attnum > 0 AND NOT a.attisdropped \
2639    AND n.nspname <> 'information_schema' AND n.nspname NOT LIKE 'pg\\_%'";
2640
2641/// Every foreign key (partitions' inherited copies left out), as JSON for `ForeignKey`.
2642const FOREIGN_KEYS_SQL: &str = "SELECT coalesce(json_agg(json_build_object( \
2643    'child', format('%I.%I', cn.nspname, cc.relname), \
2644    'child_cols', (SELECT json_agg(quote_ident(a.attname) ORDER BY k.i) \
2645        FROM unnest(con.conkey) WITH ORDINALITY AS k(num, i) \
2646        JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k.num), \
2647    'parent', format('%I.%I', pn.nspname, pc.relname), \
2648    'parent_cols', (SELECT json_agg(quote_ident(a.attname) ORDER BY k.i) \
2649        FROM unnest(con.confkey) WITH ORDINALITY AS k(num, i) \
2650        JOIN pg_attribute a ON a.attrelid = con.confrelid AND a.attnum = k.num))), '[]') \
2651    FROM pg_constraint con \
2652    JOIN pg_class cc ON cc.oid = con.conrelid JOIN pg_namespace cn ON cn.oid = cc.relnamespace \
2653    JOIN pg_class pc ON pc.oid = con.confrelid JOIN pg_namespace pn ON pn.oid = pc.relnamespace \
2654    WHERE con.contype = 'f' AND con.conparentid = 0";
2655
2656/// Files the staging stage leaves for the restore stage, inside the staging folder.
2657const GLOBALS_FILE: &str = "mitosis-globals.sql";
2658const DUMP_FILE: &str = "mitosis-masked.dump";
2659
2660/// Runs the subset (when `subset`) and masking statements of `m` against `database`, in one
2661/// transaction with triggers and foreign-key checks off.
2662async fn apply_mask(
2663    install: &PgInstall,
2664    opts: &StartOptions,
2665    database: &str,
2666    m: &Masking,
2667    subset: bool,
2668) -> Result<()> {
2669    let columns: Vec<ColumnInfo> =
2670        serde_json::from_str(&run_psql(install, opts, database, COLUMNS_SQL.into()).await?)?;
2671    let mut statements = Vec::new();
2672    if subset && !m.file.subset.is_empty() {
2673        let fks: Vec<ForeignKey> = serde_json::from_str(
2674            &run_psql(install, opts, database, FOREIGN_KEYS_SQL.into()).await?,
2675        )?;
2676        statements = subset_statements(&m.file, &fks)?;
2677    }
2678    statements.extend(mask_statements(&m.file, &m.salt, &columns)?);
2679    if statements.is_empty() {
2680        return Ok(());
2681    }
2682    // Only the `SET LOCAL` of the salt holds it. Nothing may echo statement text: no statement
2683    // logging (whatever the cluster's settings say), no sampled statement or transaction logging
2684    // (PG 12+/13+ settings, so set only where they exist), no failing statement in the server log,
2685    // no `LINE` excerpt from psql, no utility tracking by pg_stat_statements.
2686    let sql = format!(
2687        "\\set VERBOSITY terse\nSET log_statement = 'none';\nSET log_min_duration_statement = -1;\n\
2688         SELECT set_config(name, CASE name WHEN 'log_transaction_sample_rate' THEN '0' ELSE '-1' END, \
2689         false) FROM pg_settings WHERE name IN ('log_transaction_sample_rate', 'log_min_duration_sample');\n\
2690         SET log_min_error_statement = panic;\nSET pg_stat_statements.track_utility = off;\n\
2691         BEGIN;\nSET LOCAL session_replication_role = replica;\n{};\nCOMMIT;\n",
2692        statements.join(";\n")
2693    );
2694    run_psql(install, opts, database, sql).await.map(drop)
2695}
2696
2697/// Runs a Postgres tool against the build server, output to the build log, in its own process group.
2698fn run_tool(
2699    install: &PgInstall,
2700    tool: &str,
2701    opts: &StartOptions,
2702    groups: &Groups,
2703    args: &[&OsStr],
2704) -> Result<()> {
2705    let log = || OpenOptions::new().append(true).open(&opts.log_file);
2706    let mut child = Command::new(install.bin(tool))
2707        .arg("-h")
2708        .arg(&opts.socket_dir)
2709        .args(["-p", &opts.port.to_string(), "-U", "postgres"])
2710        .args(args)
2711        .process_group(0)
2712        .stdin(Stdio::null())
2713        .stdout(log()?)
2714        .stderr(log()?)
2715        .spawn()
2716        .map_err(|e| internal(format!("could not run {tool}: {e}")))?;
2717    let _group = GroupGuard::new(groups, child.id() as i32);
2718    let status = child
2719        .wait()
2720        .map_err(|e| internal(format!("waiting for {tool}: {e}")))?;
2721    if !status.success() {
2722        return Err(internal(format!("{tool} failed ({status})")));
2723    }
2724    Ok(())
2725}
2726
2727/// In the running staging server: subsets and masking, then the roles (without passwords) and
2728/// the database dumped into files in the staging folder.
2729async fn mask_and_dump(
2730    ctx: &Ctx,
2731    install: &PgInstall,
2732    staging: &Path,
2733    opts: &StartOptions,
2734    database: &str,
2735    m: &Masking,
2736) -> StepResult<()> {
2737    apply_mask(install, opts, database, m, true)
2738        .await
2739        .map_err(at("mask"))?;
2740    let (i, o, db, dir, g) = (
2741        install.clone(),
2742        opts.clone(),
2743        database.to_string(),
2744        staging.to_path_buf(),
2745        ctx.build_groups.clone(),
2746    );
2747    blocking(move || {
2748        let (globals, dump) = (dir.join(GLOBALS_FILE), dir.join(DUMP_FILE));
2749        let globals_args = ["--globals-only", "--no-role-passwords", "-f"].map(OsStr::new);
2750        run_tool(
2751            &i,
2752            "pg_dumpall",
2753            &o,
2754            &g,
2755            &[&globals_args[..], &[globals.as_os_str()]].concat(),
2756        )?;
2757        // --create: the database's owner, settings and grants come along. No statistics: they
2758        // are computed from the raw data (pg_dump 18 is the first to know the flag).
2759        let mut dump_args = vec!["-Fc", "--create"];
2760        if i.major >= 18 {
2761            dump_args.push("--no-statistics");
2762        }
2763        dump_args.extend(["-d", &db, "-f"]);
2764        let mut dump_args: Vec<&OsStr> = dump_args.into_iter().map(OsStr::new).collect();
2765        dump_args.push(dump.as_os_str());
2766        run_tool(&i, "pg_dump", &o, &g, &dump_args)
2767    })
2768    .await
2769    .map_err(at("dump"))
2770}
2771
2772/// Stage two of a masked build: a fresh cluster in `dir` gets the staging roles, then the
2773/// staging dump, and is stopped.
2774async fn restore_steps(
2775    ctx: &Ctx,
2776    install: &PgInstall,
2777    dir: &Path,
2778    opts: &mut StartOptions,
2779    database: &str,
2780    staging: &Path,
2781) -> StepResult<()> {
2782    let (i, d) = (install.clone(), dir.to_path_buf());
2783    blocking(move || initdb(&i, &d))
2784        .await
2785        .map_err(at("initdb"))?;
2786    let pg = start_build_server(ctx, install, dir, opts).await?;
2787    let globals = std::fs::read_to_string(staging.join(GLOBALS_FILE))
2788        .map_err(|e| ("roles", MitosisError::from(e)))?;
2789    // initdb made the postgres role already, and psql stops at the first error.
2790    let globals: String = globals
2791        .lines()
2792        .filter(|l| *l != "CREATE ROLE postgres;")
2793        .map(|l| format!("{l}\n"))
2794        .collect();
2795    run_psql(install, opts, "postgres", globals)
2796        .await
2797        .map_err(at("roles"))?;
2798    let (i, o, f, g) = (
2799        install.clone(),
2800        opts.clone(),
2801        staging.join(DUMP_FILE),
2802        ctx.build_groups.clone(),
2803    );
2804    // -C makes the database from the dump (owner, settings, grants); `postgres` exists already.
2805    let create = database != "postgres";
2806    blocking(move || {
2807        let mut args = vec![OsStr::new("-d"), OsStr::new("postgres")];
2808        if create {
2809            args.push(OsStr::new("-C"));
2810        }
2811        args.push(f.as_os_str());
2812        run_tool(&i, "pg_restore", &o, &g, &args)
2813    })
2814    .await
2815    .map_err(at("restore"))?;
2816    let (i, d, t) = (
2817        install.clone(),
2818        dir.to_path_buf(),
2819        ctx.config().stop_timeout,
2820    );
2821    blocking(move || stop(&i, &d, Some(pg), t))
2822        .await
2823        .map_err(at("stop"))
2824}
2825
2826/// The staging cluster of a masked build. It holds raw source values, so it is stopped and
2827/// deleted on every path: by `remove`, or from a thread if the build is dropped midway.
2828struct Staging {
2829    install: PgInstall,
2830    dir: PathBuf,
2831    timeout: Duration,
2832    removed: bool,
2833}
2834
2835impl Staging {
2836    /// A staging folder of template `name` next to its build folder `build_dir`.
2837    fn new(ctx: &Ctx, install: &PgInstall, name: &str, build_dir: &Path) -> Staging {
2838        let mut ts = now();
2839        let dir = loop {
2840            let d = ctx.home.templates_dir().join(build_dir_name(name, ts));
2841            if d != build_dir && !d.exists() {
2842                break d;
2843            }
2844            ts += 1;
2845        };
2846        Staging {
2847            install: install.clone(),
2848            dir,
2849            timeout: ctx.config().stop_timeout,
2850            removed: false,
2851        }
2852    }
2853
2854    async fn remove(mut self, log: &Path) {
2855        self.removed = true;
2856        let (i, d, t) = (self.install.clone(), self.dir.clone(), self.timeout);
2857        if let Err(e) = blocking(move || stop_and_remove(&i, &d, t, None)).await {
2858            append_log(
2859                log,
2860                &format!(
2861                    "mitosis: cleanup of staging {} failed: {e}",
2862                    self.dir.display()
2863                ),
2864            );
2865        }
2866    }
2867}
2868
2869impl Drop for Staging {
2870    fn drop(&mut self) {
2871        if !self.removed {
2872            let (i, d, t) = (self.install.clone(), self.dir.clone(), self.timeout);
2873            std::thread::spawn(move || stop_and_remove(&i, &d, t, None));
2874        }
2875    }
2876}
2877
2878/// The two-stage build: the source is built into a staging cluster, subset and masked there,
2879/// and only a dump of the result is restored into a fresh cluster in `build_dir`, so the
2880/// generation's data files and WAL never hold raw values.
2881/// The caller removes `staging` afterwards, on every path.
2882#[allow(clippy::too_many_arguments)]
2883async fn masked_steps(
2884    ctx: &Ctx,
2885    install: &PgInstall,
2886    staging: &Staging,
2887    build_dir: &Path,
2888    opts: &mut StartOptions,
2889    database: &str,
2890    source: &TemplateSource,
2891    m: &Masking,
2892) -> StepResult<Vec<String>> {
2893    let extensions = run_steps(ctx, install, &staging.dir, opts, database, source, Some(m)).await?;
2894    restore_steps(ctx, install, build_dir, opts, database, &staging.dir).await?;
2895    Ok(extensions)
2896}
2897
2898/// Step 7: measures the built side folder `row.dir` (filling `pg_major` and `size_bytes`), then
2899/// commits it. On failure the folder is removed and the error has the build-failure shape.
2900pub(crate) async fn commit_built(
2901    ctx: &Ctx,
2902    install: &PgInstall,
2903    log: &Path,
2904    secrets: &[String],
2905    mut row: GenerationRow,
2906    expected: u32,
2907) -> Result<()> {
2908    let dir = row.dir.clone();
2909    let b = dir.clone();
2910    let measured = blocking(move || Ok((read_pg_version(&b)?, apparent_size(&b)?))).await;
2911    (row.pg_major, row.size_bytes) = match measured {
2912        Ok(v) => v,
2913        Err(e) => return Err(fail(ctx, install, &dir, log, secrets, "measure", e).await),
2914    };
2915    if let Err(e) = commit_generation(ctx, row, Some(expected)).await {
2916        return Err(fail(ctx, install, &dir, log, secrets, "commit", e).await);
2917    }
2918    Ok(())
2919}
2920
2921/// Moves the built folder `gen.dir` to `templates/<name>@<gen>`, records the generation and, when
2922/// the current pointer still equals `expected_current`, makes it current; then prunes.
2923/// True when it became current; otherwise it is kept as a non-current generation.
2924pub async fn commit_generation(
2925    ctx: &Ctx,
2926    mut gen: GenerationRow,
2927    expected_current: Option<u32>,
2928) -> Result<bool> {
2929    let side = gen.dir.clone();
2930    let final_dir = ctx
2931        .home
2932        .templates_dir()
2933        .join(gen_dir_name(&gen.template, gen.gen));
2934    let current = {
2935        // Under the write guard, gc and clone creation never see the folder without its row.
2936        let _swap = ctx.template_swap.write().await;
2937        let (b, s, f) = (ctx.backend.clone(), side.clone(), final_dir.clone());
2938        blocking(move || b.rename_dir(&s, &f)).await?;
2939        gen.dir = final_dir;
2940        let store = ctx.store();
2941        if let Err(e) = store.insert_generation(&gen) {
2942            // Back to the side folder, which the failed build then removes.
2943            let _ = ctx.backend.rename_dir(&gen.dir, &side);
2944            return Err(e);
2945        }
2946        store.set_current(&gen.template, gen.gen, expected_current)?
2947    };
2948    if !current {
2949        crate::lifecycle::log(
2950            ctx,
2951            &format!(
2952                "template {}: generation {} kept as non-current: the current generation changed during the build",
2953                gen.template, gen.gen
2954            ),
2955        );
2956    }
2957    let kind = match gen.kind {
2958        GenKind::Promoted => events::TEMPLATE_PROMOTED,
2959        GenKind::Upgraded => events::TEMPLATE_UPGRADED,
2960        GenKind::Full | GenKind::Layered => events::TEMPLATE_BUILT,
2961    };
2962    events::emit(
2963        ctx,
2964        kind,
2965        None,
2966        Some(&gen.template),
2967        serde_json::json!({
2968            "gen": gen.gen,
2969            "kind": gen.kind,
2970            "current": current,
2971            "pg_major": gen.pg_major,
2972            "size_bytes": gen.size_bytes,
2973            "from_clone": gen.from_clone,
2974            "from_gen": gen.from_gen,
2975        }),
2976    );
2977    prune(ctx, &gen.template).await;
2978    Ok(current)
2979}
2980
2981/// Removes generations beyond the newest `keep`, never the current one: rows first, then the
2982/// folders, renamed aside under the write guard (no clone reads them meanwhile) and deleted
2983/// outside it. Failures are logged; gc removes what is left.
2984pub(crate) async fn prune(ctx: &Ctx, name: &str) {
2985    let (gens, doomed): (Vec<u32>, Vec<PathBuf>) = match prune_rows(ctx, name) {
2986        Ok(d) => d.into_iter().unzip(),
2987        Err(e) => {
2988            return crate::lifecycle::log(ctx, &format!("prune of template {name}: {e}"));
2989        }
2990    };
2991    if doomed.is_empty() {
2992        return;
2993    }
2994    events::emit(
2995        ctx,
2996        events::TEMPLATE_PRUNED,
2997        None,
2998        Some(name),
2999        serde_json::json!({ "gens": gens }),
3000    );
3001    let mut old = Vec::new();
3002    {
3003        let _swap = ctx.template_swap.write().await;
3004        for dir in doomed {
3005            let mut ts = now();
3006            let to = loop {
3007                let to = ctx.home.templates_dir().join(old_dir_name(name, ts));
3008                if !to.exists() {
3009                    break to;
3010                }
3011                ts += 1;
3012            };
3013            match ctx.backend.rename_dir(&dir, &to) {
3014                Ok(()) => old.push(to),
3015                Err(_) if !dir.exists() => {}
3016                Err(e) => {
3017                    crate::lifecycle::log(ctx, &format!("prune: move {} aside: {e}", dir.display()))
3018                }
3019            }
3020        }
3021    }
3022    let backend = ctx.backend.clone();
3023    let _ = blocking(move || {
3024        for dir in old {
3025            let _ = backend.remove_dir(&dir);
3026        }
3027        Ok(())
3028    })
3029    .await;
3030    crate::lifecycle::emit_kept(ctx);
3031}
3032
3033/// Deletes the rows of the generations to prune; returns their numbers and folders.
3034fn prune_rows(ctx: &Ctx, name: &str) -> Result<Vec<(u32, PathBuf)>> {
3035    let store = ctx.store();
3036    let Some(t) = store.get_template(name)? else {
3037        return Ok(Vec::new());
3038    };
3039    // A branch template keeps as many generations as its base.
3040    let keep = match &t.base {
3041        Some(base) => store.get_template(base)?.map_or(t.keep, |b| b.keep),
3042        None => t.keep,
3043    };
3044    let gens = store.list_generations(name)?;
3045    let cut = gens.len().saturating_sub(keep as usize);
3046    let mut dirs = Vec::new();
3047    for g in gens[..cut].iter().filter(|g| g.gen != t.current_gen) {
3048        store.delete_generation(name, g.gen)?;
3049        dirs.push((g.gen, g.dir.clone()));
3050    }
3051    Ok(dirs)
3052}
3053
3054#[cfg(test)]
3055mod tests {
3056    use super::*;
3057
3058    #[test]
3059    fn build_port_skips_reserved_ports() {
3060        let tmp = tempfile::Builder::new()
3061            .prefix("mt")
3062            .tempdir_in(crate::paths::test_tmp())
3063            .unwrap();
3064        // A random 20-port slice of 55000-59999, so parallel tests rarely meet.
3065        let lo = 55_000 + u16::from_str_radix(&random_hex(2), 16).unwrap() % 250 * 20;
3066        let config = crate::ctx::Config {
3067            port_range: lo..=lo + 19,
3068            ..crate::ctx::Config::default()
3069        };
3070        let ctx = Ctx::open(crate::paths::Home::at(tmp.path().to_path_buf()), config).unwrap();
3071        let lowest = build_port(&ctx, &HashSet::new()).unwrap();
3072        ctx.store()
3073            .upsert_reservation(&crate::state::ReservationRow {
3074                name: "x".into(),
3075                port: lowest,
3076                password: "pw".into(),
3077                template: "main".into(),
3078                last_used: 0,
3079            })
3080            .unwrap();
3081        assert_ne!(build_port(&ctx, &HashSet::new()).unwrap(), lowest);
3082    }
3083
3084    // Built at runtime so no `scheme://user:password@host` literal sits in the source.
3085    fn url(scheme: &str, user: &str, pass: &str, rest: &str) -> String {
3086        format!("{scheme}://{user}:{pass}@{rest}")
3087    }
3088
3089    #[test]
3090    fn database_names() {
3091        assert_eq!(
3092            db_from_url(&url("postgres", "u", "p", "h:5432/shop?sslmode=require")),
3093            "shop"
3094        );
3095        assert_eq!(db_from_url("postgres://h:5432"), "postgres");
3096        assert_eq!(db_from_url("postgres://h/"), "postgres");
3097        let args = |source, database: Option<&str>| TemplateInitArgs {
3098            name: "main".into(),
3099            source,
3100            database: database.map(String::from),
3101            migrations: None,
3102            max_age_secs: None,
3103            pg_major: None,
3104            settings: TemplateSettings::default(),
3105        };
3106        let seed = TemplateSource::Seed {
3107            command: "true".into(),
3108            cwd: None,
3109            path: None,
3110        };
3111        assert_eq!(database_for(&args(seed.clone(), None)).unwrap(), "app");
3112        assert_eq!(database_for(&args(seed.clone(), Some("x"))).unwrap(), "x");
3113        assert_eq!(
3114            database_for(&args(seed, Some("a=b"))).unwrap_err().code,
3115            ErrorCode::InvalidRequest
3116        );
3117    }
3118
3119    #[test]
3120    fn quoting() {
3121        assert_eq!(quote_ident(r#"we"ird"#), r#""we""ird""#);
3122        assert_eq!(url_encode("my db/x"), "my%20db%2Fx");
3123    }
3124
3125    #[test]
3126    fn redacts_urls_inside_text() {
3127        assert_eq!(
3128            redact_url(&format!(
3129                "psql '{}' && psql {}",
3130                url("postgres", "a", "pw", "h/db"),
3131                url("postgresql", "b", "pw2", "h2:1/x")
3132            )),
3133            "psql 'postgres://a:***@h/db' && psql postgresql://b:***@h2:1/x"
3134        );
3135        assert_eq!(redact_url("postgres://u@h/db"), "postgres://u@h/db");
3136    }
3137
3138    #[test]
3139    fn redacts_password_env_assignments() {
3140        assert_eq!(
3141            redact_url("PGPASSWORD=s3cret psql -c 'select 1'"),
3142            "PGPASSWORD=*** psql -c 'select 1'"
3143        );
3144        assert_eq!(
3145            redact_url("DB_PASSWORD='a b' PGPASSWORD=\"x y\" run"),
3146            "DB_PASSWORD=*** PGPASSWORD=*** run"
3147        );
3148        assert_eq!(redact_url("echo PASSWORD= done"), "echo PASSWORD= done");
3149    }
3150
3151    #[test]
3152    fn migrate_cwd_must_be_a_folder() {
3153        let tmp = tempfile::tempdir().unwrap();
3154        let migrate = |cwd: PathBuf| TemplateSettings {
3155            migrate: Some(crate::protocol::MigrateCmd {
3156                command: "make".into(),
3157                cwd,
3158                path: None,
3159            }),
3160            ..Default::default()
3161        };
3162        let mut t = TemplateRow::new("m", "app", TemplateSource::Url { url: "x".into() }, None, 1);
3163        apply_settings(&mut t, None, &migrate(tmp.path().to_path_buf())).unwrap();
3164        let e = apply_settings(&mut t, None, &migrate(tmp.path().join("gone"))).unwrap_err();
3165        assert!(e.message.contains("not a folder"), "{}", e.message);
3166    }
3167
3168    #[test]
3169    fn build_log_name_is_safe_for_branch_templates() {
3170        let home = Home::at(PathBuf::from("/h"));
3171        assert_eq!(
3172            build_log_path(&home, "main"),
3173            home.logs_dir().join("template-main.log")
3174        );
3175        let log = build_log_path(&home, &branch_template_name("main", "feature/x y"));
3176        let file = log.file_name().unwrap().to_str().unwrap();
3177        assert_eq!(log.parent().unwrap(), home.logs_dir());
3178        assert!(file.starts_with("template-main~feature-x-y-"), "{file}");
3179    }
3180
3181    #[test]
3182    fn password_is_split_out_of_the_pg_dump_url() {
3183        let s = split_password(&url(
3184            "postgres",
3185            "u",
3186            "s%40cr%3Aet",
3187            "h:5432/db?sslmode=require",
3188        ))
3189        .unwrap();
3190        assert_eq!(s.url, "postgres://u@h:5432/db?sslmode=require");
3191        assert_eq!(s.password.as_deref(), Some("s@cr:et"));
3192        assert_eq!(s.raw_password.as_deref(), Some("s%40cr%3Aet"));
3193        assert!(!s.url.contains("s%40cr") && !s.url.contains("s@cr"));
3194
3195        let none = split_password("postgres://u@h/db").unwrap();
3196        assert_eq!(
3197            (none.url.as_str(), none.password),
3198            ("postgres://u@h/db", None)
3199        );
3200        assert_eq!(split_password("postgres://h/db").unwrap().password, None);
3201
3202        let e = split_password(&url("postgres", "u", "pa%zz", "h/db")).unwrap_err();
3203        assert_eq!(e.code, ErrorCode::InvalidRequest);
3204        assert!(!e.to_string().contains("pa%zz"), "{e}");
3205        assert!(split_password(&url("postgres", "u", "pa%4", "h/db")).is_err()); // truncated escape
3206        assert!(split_password(&url("postgres", "u", "pa%+1", "h/db")).is_err());
3207        // sign is not a hex digit
3208    }
3209
3210    #[test]
3211    fn query_password_is_split_out_and_redacted() {
3212        let s = split_password("postgres://u@h/db?sslmode=require&password=s%40cr&x=1").unwrap();
3213        assert_eq!(s.url, "postgres://u@h/db?sslmode=require&x=1");
3214        assert_eq!(s.password.as_deref(), Some("s@cr"));
3215        assert_eq!(s.raw_password.as_deref(), Some("s%40cr"));
3216
3217        let only = split_password("postgres://u@h/db?password=pw").unwrap();
3218        assert_eq!(only.url, "postgres://u@h/db");
3219
3220        // The query password wins over the userinfo one.
3221        let both = split_password(&url("postgres", "u", "old", "h/db?password=new")).unwrap();
3222        assert_eq!(
3223            (both.url.as_str(), both.password.as_deref()),
3224            ("postgres://u@h/db", Some("new"))
3225        );
3226
3227        assert!(split_password("postgres://u@h/db?password=pa%zz").is_err());
3228
3229        assert_eq!(
3230            redact_url("pg_dump 'postgres://u@h/db?sslmode=x&password=pw#f' ok"),
3231            "pg_dump 'postgres://u@h/db?sslmode=x&password=***#f' ok"
3232        );
3233        assert_eq!(
3234            redact_url("postgres://u@h/db?sslmode=x"),
3235            "postgres://u@h/db?sslmode=x"
3236        );
3237    }
3238
3239    #[test]
3240    fn invalid_password_encoding_rejected_by_init_without_echo() {
3241        let tmp = tempfile::tempdir().unwrap();
3242        let ctx = Ctx::open(
3243            crate::paths::Home::at(tmp.path().join("m")),
3244            Default::default(),
3245        )
3246        .unwrap();
3247        let args = TemplateInitArgs {
3248            name: "main".into(),
3249            source: TemplateSource::Url {
3250                url: url("postgres", "u", "pa%zz", "h/db"),
3251            },
3252            database: None,
3253            migrations: None,
3254            max_age_secs: None,
3255            pg_major: None,
3256            settings: TemplateSettings::default(),
3257        };
3258        let rt = tokio::runtime::Builder::new_current_thread()
3259            .enable_all()
3260            .build()
3261            .unwrap();
3262        let e = rt.block_on(init(&ctx, args)).unwrap_err();
3263        assert_eq!(e.code, ErrorCode::InvalidRequest);
3264        assert!(!e.to_string().contains("pa%zz"), "{e}");
3265    }
3266
3267    #[test]
3268    fn secrets_are_scrubbed_from_text() {
3269        let src = TemplateSource::Url {
3270            url: url("postgres", "u", "p%40ss", "h/db"),
3271        };
3272        let secrets = source_secrets(&src);
3273        assert_eq!(secrets, vec!["p%40ss".to_string(), "p@ss".to_string()]);
3274        let text = format!(
3275            "invalid token: \"p%40ss\"; also p@ss and {}",
3276            url("postgres", "x", "y", "h")
3277        );
3278        assert_eq!(
3279            scrub(&text, &secrets),
3280            "invalid token: \"***\"; also *** and postgres://x:***@h"
3281        );
3282        assert!(source_secrets(&TemplateSource::Seed {
3283            command: "x".into(),
3284            cwd: None,
3285            path: None
3286        })
3287        .is_empty());
3288    }
3289
3290    #[test]
3291    fn leftover_names_round_trip() {
3292        assert_eq!(
3293            parse_leftover(&build_dir_name("main", 17)),
3294            Some(Leftover::Building("main".into()))
3295        );
3296        assert_eq!(
3297            parse_leftover(&old_dir_name("my-db", 42)),
3298            Some(Leftover::Old("my-db".into(), 42))
3299        );
3300        for bad in [
3301            "main",
3302            ".DS_Store",
3303            ".main.old-",
3304            ".main.old-1x",
3305            ".main.building-",
3306            ".Main.old-1",
3307            ".main.backup-1",
3308            ".git",
3309        ] {
3310            assert_eq!(parse_leftover(bad), None, "{bad}");
3311        }
3312        assert!(is_build_socket_name("build-54321"));
3313        for bad in ["build-", "build-x", "build-99999", "build-+1", "cabc123"] {
3314            assert!(!is_build_socket_name(bad), "{bad}");
3315        }
3316        assert_eq!(
3317            parse_gen_dir(&gen_dir_name("my-db", 12)),
3318            Some(("my-db".into(), 12))
3319        );
3320        for bad in [
3321            "main", "main@", "main@x", "main@+1", "Main@1", "@1", "main@1@2", ".main@1",
3322        ] {
3323            assert_eq!(parse_gen_dir(bad), None, "{bad}");
3324        }
3325    }
3326
3327    #[test]
3328    fn build_guard_is_exclusive_per_name() {
3329        let tmp = tempfile::tempdir().unwrap();
3330        let ctx = Ctx::open(
3331            crate::paths::Home::at(tmp.path().join("m")),
3332            Default::default(),
3333        )
3334        .unwrap();
3335        let g = BuildGuard::take(&ctx, "main").unwrap();
3336        let e = BuildGuard::take(&ctx, "main").err().unwrap();
3337        assert_eq!(e.code, ErrorCode::InvalidRequest);
3338        assert_eq!(e.message, "template main is already being built");
3339        assert!(BuildGuard::take(&ctx, "other").is_ok());
3340        drop(g);
3341        assert!(BuildGuard::take(&ctx, "main").is_ok());
3342    }
3343
3344    /// A back end whose external snapshot is the fixture folder `snap`; records destroys.
3345    struct FakeSnap {
3346        snap: PathBuf,
3347        destroyed: Mutex<Vec<String>>,
3348    }
3349
3350    impl CloneBackend for FakeSnap {
3351        fn name(&self) -> &'static str {
3352            "zfs"
3353        }
3354        fn check_supported(&self, _: &Path) -> Result<()> {
3355            Ok(())
3356        }
3357        fn clone_dir(&self, _: &Path, _: &Path) -> Result<()> {
3358            unreachable!()
3359        }
3360        fn create_dir(&self, _: &Path) -> Result<()> {
3361            unreachable!()
3362        }
3363        fn remove_dir(&self, _: &Path) -> Result<()> {
3364            unreachable!()
3365        }
3366        fn rename_dir(&self, _: &Path, _: &Path) -> Result<()> {
3367            unreachable!()
3368        }
3369        fn private_size(&self, _: &Path) -> Option<u64> {
3370            None
3371        }
3372        fn snapshot_external(&self, _: &str, _: &str) -> Result<PathBuf> {
3373            Ok(self.snap.clone())
3374        }
3375        fn destroy_external(&self, dataset: &str, name: &str) -> Result<()> {
3376            self.destroyed
3377                .lock()
3378                .unwrap()
3379                .push(format!("{dataset}@{name}"));
3380            Ok(())
3381        }
3382    }
3383
3384    /// A fixture data directory as a snapshot of a running server shows it.
3385    fn running_fixture() -> (tempfile::TempDir, FakeSnap) {
3386        let tmp = tempfile::tempdir().unwrap();
3387        let snap = tmp.path().join("snap");
3388        std::fs::create_dir_all(snap.join("pg_wal")).unwrap();
3389        std::fs::create_dir_all(snap.join("base/1")).unwrap();
3390        std::fs::write(snap.join("PG_VERSION"), "17\n").unwrap();
3391        std::fs::write(snap.join("base/1/1259"), "rows").unwrap();
3392        std::fs::write(snap.join("postmaster.pid"), "4242\n").unwrap();
3393        let b = FakeSnap {
3394            snap,
3395            destroyed: Mutex::new(Vec::new()),
3396        };
3397        (tmp, b)
3398    }
3399
3400    #[test]
3401    fn from_running_needs_zfs_backend() {
3402        let tmp = tempfile::tempdir().unwrap();
3403        let ctx = Ctx::open_with_backend(
3404            crate::paths::Home::at(tmp.path().join("m")),
3405            Default::default(),
3406            Arc::new(crate::clone_backend::CopyBackend),
3407        )
3408        .unwrap();
3409        let rt = tokio::runtime::Runtime::new().unwrap();
3410        let e = rt
3411            .block_on(running_install(&ctx, "tank/pg", None))
3412            .err()
3413            .unwrap();
3414        assert_eq!(e.code, ErrorCode::BackendUnsupported);
3415        assert!(e.message.contains("--from-running"), "{}", e.message);
3416    }
3417
3418    #[test]
3419    fn foreign_owner_is_refused() {
3420        if unsafe { libc::geteuid() } == 0 {
3421            return; // root owns "/"
3422        }
3423        let e = check_running_dir(Path::new("/"), "tank/pg").unwrap_err();
3424        assert_eq!(e.code, ErrorCode::InvalidRequest);
3425        assert!(
3426            e.message
3427                .starts_with("the source data directory is owned by ")
3428                && e.message.ends_with("; Mitosis must run as that user"),
3429            "{}",
3430            e.message
3431        );
3432        let (_tmp, b) = running_fixture();
3433        assert_eq!(check_running_dir(&b.snap, "tank/pg").unwrap(), 17);
3434        std::fs::remove_file(b.snap.join("PG_VERSION")).unwrap();
3435        let e = check_running_dir(&b.snap, "tank/pg").unwrap_err();
3436        assert!(e.message.contains("no PG_VERSION"), "{}", e.message);
3437    }
3438
3439    #[test]
3440    fn outside_tablespace_is_refused() {
3441        let (tmp, b) = running_fixture();
3442        std::fs::create_dir_all(b.snap.join("pg_tblspc")).unwrap();
3443        std::os::unix::fs::symlink("/data/ts1", b.snap.join("pg_tblspc/16385")).unwrap();
3444        let dst = tmp.path().join("build");
3445        let e = copy_running(&b, "tank/pg", &dst).unwrap_err();
3446        assert_eq!(e.code, ErrorCode::InvalidRequest);
3447        assert!(
3448            e.message.contains("pg_tblspc/16385")
3449                && e.message
3450                    .contains("tablespaces must be on the same dataset"),
3451            "{}",
3452            e.message
3453        );
3454        assert!(!dst.exists());
3455        assert_eq!(b.destroyed.lock().unwrap().len(), 1);
3456    }
3457
3458    #[test]
3459    fn symlinked_pg_wal_is_refused() {
3460        let (tmp, b) = running_fixture();
3461        std::fs::remove_dir(b.snap.join("pg_wal")).unwrap();
3462        std::os::unix::fs::symlink("/elsewhere/pg_wal", b.snap.join("pg_wal")).unwrap();
3463        let dst = tmp.path().join("build");
3464        let e = copy_running(&b, "tank/pg", &dst).unwrap_err();
3465        assert_eq!(e.code, ErrorCode::InvalidRequest);
3466        assert!(
3467            e.message.contains("pg_wal must be on the same dataset"),
3468            "{}",
3469            e.message
3470        );
3471        assert!(!dst.exists());
3472        // Destroyed on the error path too.
3473        assert_eq!(b.destroyed.lock().unwrap().len(), 1);
3474    }
3475
3476    #[test]
3477    fn standby_signal_is_refused() {
3478        for signal in ["standby.signal", "recovery.signal"] {
3479            let (tmp, b) = running_fixture();
3480            std::fs::write(b.snap.join(signal), "").unwrap();
3481            let e = copy_running(&b, "tank/pg", &tmp.path().join("build")).unwrap_err();
3482            assert_eq!(e.code, ErrorCode::InvalidRequest);
3483            assert!(
3484                e.message.contains("replica sources come in 1.2"),
3485                "{}",
3486                e.message
3487            );
3488            assert_eq!(b.destroyed.lock().unwrap().len(), 1);
3489        }
3490    }
3491
3492    #[test]
3493    fn postmaster_pid_is_removed_before_start() {
3494        let (tmp, b) = running_fixture();
3495        let dst = tmp.path().join("build");
3496        copy_running(&b, "tank/pg", &dst).unwrap();
3497        assert!(!dst.join("postmaster.pid").exists());
3498        assert_eq!(
3499            std::fs::read_to_string(dst.join("base/1/1259")).unwrap(),
3500            "rows"
3501        );
3502        assert_eq!(read_pg_version(&dst).unwrap(), 17);
3503        // The source is never written.
3504        assert!(b.snap.join("postmaster.pid").exists());
3505        let destroyed = b.destroyed.lock().unwrap();
3506        assert_eq!(destroyed.len(), 1);
3507        assert!(
3508            destroyed[0].starts_with("tank/pg@mitosis-"),
3509            "{destroyed:?}"
3510        );
3511    }
3512
3513    // The daemon spawns these futures on a multi-thread runtime, so they must be Send.
3514    #[allow(dead_code)]
3515    fn futures_are_send(ctx: &'static Ctx, args: TemplateInitArgs) {
3516        fn send<T: Send>(_: T) {}
3517        send(init(ctx, args));
3518        send(refresh(ctx, "main", None));
3519        send(rollback(ctx, "main", None));
3520        send(layered(ctx, "main", None));
3521        send(background(ctx, "main", RefreshMode::Layered));
3522    }
3523}