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";
36const 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
54async 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 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 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 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
136type GitPaths = (Option<String>, Option<PathBuf>, Option<PathBuf>);
139
140fn clear_repo_paths(t: &mut TemplateRow) {
143 (t.repo_rel_cwd, t.repo_rel_migrations) = (None, None);
144}
145
146fn 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
154pub(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
170fn 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
188fn 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
205pub 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
212fn 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 max_age_secs: None,
240 pg_major: Some(cur.pg_major),
241 settings: TemplateSettings::default(),
242 })
243}
244
245pub 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 t.last_attempt_at = None;
263 }
264 store.update_template_settings(&t)?;
265 }
266 template_info(ctx, &args.name)
267}
268
269fn 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 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 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
372pub 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
386pub(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 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
485pub 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 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
534pub 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
567fn 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
589fn 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#[derive(Debug, PartialEq)]
611struct SplitUrl {
612 url: String,
614 password: Option<String>,
616 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
639fn 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
691fn 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
709fn 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); }
735 }
736 Ok(())
737}
738
739pub 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
757fn 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 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 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
807fn 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
821pub(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
854pub(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
862pub 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
870fn 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
880pub(crate) async fn background(ctx: &Ctx, name: &str, mode: RefreshMode) {
885 let _guard = BuildGuard {
887 ctx,
888 name: name.to_string(),
889 };
890 let started = now();
891 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, ¤t);
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 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 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
982struct 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
1015pub(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
1028fn 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 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#[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 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 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 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
1162fn build_socket_dir(ctx: &Ctx, port: u16) -> PathBuf {
1165 ctx.home.sockets_dir().join(format!("build-{port}"))
1166}
1167
1168pub(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#[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
1191pub(crate) fn build_log_path(home: &Home, name: &str) -> PathBuf {
1194 home.logs_dir()
1195 .join(format!("template-{}.log", folder_stem(name)))
1196}
1197
1198fn gen_dir_name(name: &str, gen: u32) -> String {
1200 format!("{}@{gen}", folder_stem(name))
1201}
1202
1203pub fn branch_template_name(base: &str, branch: &str) -> String {
1205 format!("{base}~{branch}")
1206}
1207
1208pub 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
1227pub(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
1235fn 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
1249pub 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
1260fn 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
1272pub(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
1282pub(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 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
1374const MISSING_DATABASE_STEP: &str = "missing database";
1377const SNAPSHOT_STEP: &str = "snapshot copy";
1380
1381fn 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
1411async 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
1438fn 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
1449fn 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
1493fn 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
1505fn 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
1528async 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
1558fn 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 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
1590fn copy_snapshot(snap: &Path, dataset: &str, data_dir: &Path) -> Result<()> {
1593 check_running_snapshot(snap, dataset)?;
1594 if !data_dir.exists() {
1595 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
1605fn 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
1617async 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
1656async 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
1679async 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 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
1725async 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 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
1757async 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
1792fn 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
1811async 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 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
1850async 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
1856async 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 (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 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 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 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 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
1958pub(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
1967pub 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(¤t)
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
1985fn branch_label(branch: &str) -> String {
1987 if branch.is_empty() {
1988 "a detached HEAD".into()
1989 } else {
1990 format!("branch {branch}")
1991 }
1992}
1993
1994async 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
2010fn 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
2039async 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
2076struct Layer<'a> {
2080 target: &'a str,
2081 src: &'a TemplateRow,
2082 from: &'a GenerationRow,
2083 migrate: MigrateCmd,
2084 migrations: Option<PathBuf>,
2086 expected: u32,
2087}
2088
2089async fn build_layer(ctx: &Ctx, l: Layer<'_>) -> Result<u32> {
2091 let (name, t, cur) = (l.target, l.src, l.from);
2092 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 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); 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#[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 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#[derive(Debug)]
2217pub struct BranchUse {
2218 pub name: String,
2219 pub rebuilt: bool,
2221 pub stale: bool,
2223 pub refreshing: bool,
2225 pub warnings: Vec<String>,
2227}
2228
2229const BRANCH_BUILD_WAIT: Duration = Duration::from_secs(30 * 60);
2231
2232struct Checkout {
2234 cwd: PathBuf,
2235 migrations: Option<PathBuf>,
2236}
2237
2238pub 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 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 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
2321async fn background_branch(ctx: &Ctx, base: &str, name: &str, b: &BranchArgs) {
2325 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(¤t)
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
2387type BranchRow = (
2390 TemplateRow,
2391 TemplateRow,
2392 GenerationRow,
2393 Checkout,
2394 Option<String>,
2395);
2396
2397async 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 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 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
2473async 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
2516fn 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
2526fn 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#[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 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 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 if let Some(st) = staging {
2605 st.remove(&log).await;
2606 }
2607 let _ = std::fs::remove_dir(&opts.socket_dir); 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
2626struct Masking {
2628 file: MaskFile,
2629 hash: String,
2630 salt: String,
2631}
2632
2633const 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
2641const 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
2656const GLOBALS_FILE: &str = "mitosis-globals.sql";
2658const DUMP_FILE: &str = "mitosis-masked.dump";
2659
2660async 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 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
2697fn 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
2727async 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 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
2772async 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 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 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
2826struct Staging {
2829 install: PgInstall,
2830 dir: PathBuf,
2831 timeout: Duration,
2832 removed: bool,
2833}
2834
2835impl Staging {
2836 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#[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
2898pub(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
2921pub 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 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 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
2981pub(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
3033fn 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 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 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 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()); assert!(split_password(&url("postgres", "u", "pa%+1", "h/db")).is_err());
3207 }
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 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 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 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; }
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 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 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 #[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}