1use crate::budget;
2use crate::client;
3use crate::config::{Config, ConfigEntry};
4use crate::daemon;
5use crate::doctor;
6use crate::envfile;
7use crate::error::{ErrorCode, MitosisError, Result};
8use crate::events;
9use crate::mcp;
10use crate::names::{format_duration, parse_duration, parse_wait, validate_name};
11use crate::paths::Home;
12use crate::protocol::{
13 BranchArgs, CloneCreateArgs, CloneInfo, EventFilter, EventRow, MigrateCmd, Request, Response,
14 TemplateInfo, TemplateInitArgs, TemplateSetArgs, TemplateSettings, TemplateSource,
15};
16use crate::service;
17use crate::templates::now;
18use clap::{Parser, Subcommand};
19use std::process::ExitCode;
20use std::time::{Duration, Instant};
21
22#[derive(Parser)]
23#[command(
24 name = "mitosis",
25 version,
26 about = "Copy-on-write Postgres clones for AI agent tasks"
27)]
28struct Cli {
29 #[command(subcommand)]
30 command: Cmd,
31}
32
33#[allow(clippy::large_enum_variant)]
35#[derive(Subcommand)]
36enum Cmd {
37 Template {
39 #[command(subcommand)]
40 command: TemplateCmd,
41 },
42 Clone {
44 #[arg(long, default_value = "main")]
45 template: String,
46 #[arg(long)]
47 name: Option<String>,
48 #[arg(long, default_value = "2h")]
50 ttl: String,
51 #[arg(long)]
53 durable: bool,
54 #[arg(long)]
56 fresh: bool,
57 #[arg(long)]
59 protect: bool,
60 #[arg(long)]
62 env_file: Option<std::path::PathBuf>,
63 #[arg(long, requires = "env_file")]
65 env_var: Option<String>,
66 #[arg(long)]
68 wait: Option<String>,
69 #[arg(long, num_args = 0..=1, default_missing_value = "")]
72 branch: Option<String>,
73 #[arg(long)]
74 json: bool,
75 },
76 Env {
78 id_or_name: String,
79 #[arg(long, default_value = "DATABASE_URL")]
80 var: String,
81 #[arg(long)]
84 direct: bool,
85 },
86 List {
88 #[arg(long)]
89 json: bool,
90 },
91 Top,
93 Destroy {
95 id_or_name: String,
96 #[arg(long)]
98 force: bool,
99 #[arg(long)]
101 forget: bool,
102 },
103 Wake { id_or_name: String },
105 Diff {
107 id_or_name: String,
108 #[arg(long)]
110 table: Option<String>,
111 #[arg(long, requires = "table")]
113 limit: Option<u32>,
114 #[arg(long)]
115 json: bool,
116 },
117 Promote {
119 id_or_name: String,
120 #[arg(long)]
122 template: Option<String>,
123 },
124 Gc,
126 Events {
128 #[arg(long)]
130 follow: bool,
131 #[arg(long)]
133 clone: Option<String>,
134 #[arg(long)]
136 template: Option<String>,
137 #[arg(long)]
139 kind: Option<String>,
140 #[arg(long)]
142 since: Option<String>,
143 #[arg(long, value_parser = clap::value_parser!(u32).range(1..))]
145 limit: Option<u32>,
146 #[arg(long)]
148 json: bool,
149 },
150 Pg {
152 #[command(subcommand)]
153 command: PgCmd,
154 },
155 Doctor {
157 #[arg(long)]
159 fix: bool,
160 #[arg(long)]
161 json: bool,
162 },
163 Daemon {
165 #[command(subcommand)]
166 command: Option<DaemonCmd>,
167 },
168 Mcp,
170 Service {
172 #[command(subcommand)]
173 command: ServiceCmd,
174 },
175 #[command(args_conflicts_with_subcommands = true)]
177 Config {
178 #[command(subcommand)]
179 command: Option<ConfigCmd>,
180 #[arg(long)]
181 json: bool,
182 },
183 Zfs {
185 #[command(subcommand)]
186 command: ZfsCmd,
187 },
188}
189
190#[derive(Subcommand)]
191enum ConfigCmd {
192 Set {
194 key: String,
196 value: String,
198 },
199}
200
201#[derive(Subcommand)]
202enum TemplateCmd {
203 Init {
206 #[arg(default_value = "main")]
207 name: String,
208 #[arg(
210 long,
211 conflicts_with_all = ["seed", "basebackup"],
212 required_unless_present_any = ["seed", "basebackup", "from_running"]
213 )]
214 from_url: Option<String>,
215 #[arg(
217 long,
218 value_name = "DATASET",
219 conflicts_with_all = ["from_url", "basebackup", "seed"]
220 )]
221 from_running: Option<String>,
222 #[arg(long, conflicts_with = "seed")]
224 basebackup: Option<String>,
225 #[arg(long)]
227 seed: Option<String>,
228 #[arg(long)]
230 database: Option<String>,
231 #[arg(long)]
233 migrations: Option<std::path::PathBuf>,
234 #[arg(long)]
236 pg_major: Option<u32>,
237 #[command(flatten)]
238 settings: SettingsFlags,
239 },
240 Refresh {
242 #[arg(default_value = "main")]
243 name: String,
244 #[arg(long)]
246 layered: bool,
247 },
248 Set {
250 #[arg(default_value = "main")]
251 name: String,
252 #[command(flatten)]
253 settings: SettingsFlags,
254 #[arg(long)]
256 resume: bool,
257 },
258 List {
260 #[arg(long)]
261 json: bool,
262 },
263 History {
265 #[arg(default_value = "main")]
266 name: String,
267 #[arg(long)]
268 json: bool,
269 },
270 Rollback {
272 #[arg(default_value = "main")]
273 name: String,
274 #[arg(long)]
276 to: Option<u32>,
277 },
278 Upgrade {
280 name: String,
281 #[arg(long)]
283 to: u32,
284 #[arg(long = "as")]
286 as_name: Option<String>,
287 },
288}
289
290#[derive(clap::Args)]
292struct SettingsFlags {
293 #[arg(long)]
295 max_age: Option<String>,
296 #[arg(long)]
298 keep: Option<u32>,
299 #[arg(long)]
301 every: Option<String>,
302 #[arg(long, value_parser = ["full", "layered"])]
304 every_mode: Option<String>,
305 #[arg(long)]
307 migrate: Option<String>,
308 #[arg(long, value_parser = ["on", "off"])]
310 auto_migrate: Option<String>,
311 #[arg(long, value_parser = ["warn", "refresh"])]
313 on_stale: Option<String>,
314 #[arg(long)]
316 mask: Option<std::path::PathBuf>,
317 #[arg(long)]
320 base_branch: Option<String>,
321}
322
323fn enum_arg<T: serde::de::DeserializeOwned>(s: Option<String>) -> Result<Option<T>> {
325 Ok(
326 s.map(|s| serde_json::from_value(serde_json::Value::String(s)))
327 .transpose()?,
328 )
329}
330
331impl SettingsFlags {
332 fn parse(self) -> Result<(Option<u64>, TemplateSettings)> {
334 let max_age_secs = self
335 .max_age
336 .as_deref()
337 .map(parse_duration)
338 .transpose()?
339 .map(|d| d.as_secs());
340 let (every_secs, every_off) = match self.every.as_deref() {
341 Some("off") => (None, true),
342 Some(d) => (Some(parse_duration(d)?.as_secs()), false),
343 None => (None, false),
344 };
345 let (migrate, migrate_off) = match self.migrate {
346 Some(c) if c == "off" => (None, true),
347 Some(command) => (
349 Some(MigrateCmd {
350 command,
351 cwd: std::env::current_dir()?,
352 path: std::env::var("PATH").ok(),
353 }),
354 false,
355 ),
356 None => (None, false),
357 };
358 let (mask, mask_off) = match self.mask {
359 Some(p) if p.as_os_str() == "off" => (None, true),
360 Some(p) => (
362 Some(std::fs::canonicalize(&p).map_err(|e| {
363 MitosisError::new(
364 ErrorCode::InvalidRequest,
365 format!("masking file {}: {e}", p.display()),
366 )
367 })?),
368 false,
369 ),
370 None => (None, false),
371 };
372 let settings = TemplateSettings {
373 keep: self.keep,
374 every_secs,
375 every_off,
376 every_mode: enum_arg(self.every_mode)?,
377 migrate,
378 migrate_off,
379 auto_migrate: self.auto_migrate.map(|a| a == "on"),
380 on_stale: enum_arg(self.on_stale)?,
381 mask,
382 mask_off,
383 base_branch: self.base_branch.clone().filter(|b| b != "off"),
384 base_branch_off: self.base_branch.as_deref() == Some("off"),
385 };
386 Ok((max_age_secs, settings))
387 }
388}
389
390#[derive(Subcommand)]
391enum PgCmd {
392 Install {
394 major: u32,
395 #[arg(long)]
397 version: Option<String>,
398 },
399 List {
401 #[arg(long)]
402 json: bool,
403 },
404 Remove { spec: String },
406}
407
408#[derive(Subcommand)]
409enum ServiceCmd {
410 Install,
412 Uninstall,
414 Status {
416 #[arg(long)]
417 json: bool,
418 },
419}
420
421#[derive(Subcommand)]
422enum ZfsCmd {
423 Setup { dataset: String },
425}
426
427#[derive(Subcommand)]
428enum DaemonCmd {
429 Status,
431 Stop,
433}
434
435pub fn main() -> ExitCode {
436 let cli = Cli::parse();
437 let rt = match tokio::runtime::Builder::new_multi_thread()
438 .enable_all()
439 .build()
440 {
441 Ok(rt) => rt,
442 Err(e) => return fail(&MitosisError::from(e)),
443 };
444 match rt.block_on(run(cli)) {
445 Ok(code) => code,
446 Err(e) => fail(&e),
447 }
448}
449
450fn fail(e: &MitosisError) -> ExitCode {
451 eprintln!("error: {}", e.message);
452 if let Some(hint) = &e.hint {
453 eprintln!("hint: {hint}");
454 }
455 if e.code == ErrorCode::AlreadyRunning {
457 if std::env::var_os("MITOSIS_SERVICE").is_some_and(|v| v == "1") {
459 return ExitCode::SUCCESS;
460 }
461 return ExitCode::from(client::EXIT_ALREADY_RUNNING);
462 }
463 ExitCode::FAILURE
464}
465
466fn unexpected(_: Response) -> MitosisError {
468 MitosisError::new(ErrorCode::ProtocolMismatch, "unexpected daemon reply")
469 .with_hint("restart the daemon: mitosis daemon stop")
470}
471
472fn print_json<T: serde::Serialize>(v: &T) -> Result<()> {
473 println!("{}", serde_json::to_string_pretty(v)?);
474 Ok(())
475}
476
477async fn run(cli: Cli) -> Result<ExitCode> {
478 let home = Home::from_env()?;
479 match cli.command {
480 Cmd::Daemon { command: None } => {
481 let config = Config::load(&home)?;
482 daemon::run(home, config).await?
483 }
484 Cmd::Mcp => mcp::serve_stdio(home).await?,
485 Cmd::Service { command } => match command {
486 ServiceCmd::Install => {
487 service::install()?;
488 let exe = std::env::current_exe()?;
489 println!("service installed: the daemon starts at login");
490 println!("binary {}", exe.display());
491 if exe.components().any(|c| c.as_os_str() == "target") {
492 eprintln!(
493 "warning: the service points at a build folder; it breaks if you run cargo clean (use cargo install for a stable path)"
494 );
495 }
496 }
497 ServiceCmd::Uninstall => {
498 service::uninstall()?;
499 println!("service removed");
500 }
501 ServiceCmd::Status { json } => {
502 let s = service::status()?;
503 if json {
504 print_json(&s)?;
505 } else {
506 println!("installed {}", s.installed);
507 println!("loaded {}", s.loaded);
508 println!("running {}", s.running);
509 if let Some(b) = &s.binary {
510 let gone = if s.binary_exists { "" } else { " (missing)" };
511 println!("binary {}{gone}", b.display());
512 }
513 if s.installed {
514 if let Some(hint) = service::linger_hint() {
515 println!("{hint}");
516 }
517 }
518 }
519 }
520 },
521 Cmd::Config {
522 command: None,
523 json,
524 } => {
525 let entries = Config::describe(&home)?;
526 if json {
527 print_json(&entries)?;
528 } else {
529 print_config(&entries);
530 }
531 }
532 Cmd::Config {
533 command: Some(ConfigCmd::Set { key, value }),
534 ..
535 } => {
536 Config::set_in_file(&home, &key, &value)?;
537 client::request_if_running(&home, Request::ConfigReload).await?;
539 println!("{key} = {}", value.trim());
540 if let Some((var, value)) = Config::env_override(&home, &key)? {
542 eprintln!("note: {var} is set and overrides this; the value in effect is {value}");
543 }
544 }
545 Cmd::Template { command } => template(&home, command).await?,
546 Cmd::Pg { command } => pg(&home, command).await?,
547 Cmd::Clone {
548 template,
549 name,
550 ttl,
551 durable,
552 fresh,
553 protect,
554 env_file,
555 env_var,
556 wait,
557 branch,
558 json,
559 } => {
560 validate_name("template", &template)?;
561 if let Some(n) = &name {
562 validate_name("clone", n)?;
563 }
564 let ttl_secs = parse_duration(&ttl)?.as_secs();
565 let wait_secs = match wait.as_deref() {
566 None => None,
567 Some(w) => Some(parse_wait(w)?.as_secs()),
568 };
569 let args = CloneCreateArgs {
570 template: template.clone(),
571 name,
572 ttl_secs,
573 durable,
574 fresh,
575 protect,
576 parent_pid: None,
579 env: None,
580 wait_secs,
581 branch: match branch {
583 Some(name) => Some(BranchArgs {
584 name,
585 repo_root: std::env::current_dir()?,
586 path: None,
587 }),
588 None => None,
589 },
590 };
591 let info = envfile::create_clone(&home, args, env_file, env_var).await?;
592 for w in &info.warnings {
593 eprintln!("warning: {w}");
594 }
595 if info.existing {
596 eprintln!("note: clone {} was already running", info.id);
597 }
598 if info.rebuilt {
599 eprintln!(
600 "note: template {} was rebuilt for this branch",
601 info.template
602 );
603 }
604 if let Ok(Response::Templates { templates }) =
606 client::request(&home, Request::TemplateList).await
607 {
608 for w in templates
609 .iter()
610 .filter(|t| t.name == info.template)
611 .flat_map(|t| &t.warnings)
612 {
613 eprintln!("warning: {w}");
614 }
615 }
616 if json {
617 print_json(&info)?;
618 } else {
619 println!("{}", info.url);
620 }
621 }
622 Cmd::Env {
623 id_or_name,
624 var,
625 direct,
626 } => {
627 envfile::check_var(&var)?;
629 let (url, warnings) = envfile::env_url(&home, &id_or_name, direct).await?;
630 for w in &warnings {
631 eprintln!("warning: {w}");
632 }
633 println!("{var}={url}");
634 }
635 Cmd::List { json } => {
636 let clones = match client::request(&home, Request::CloneList).await? {
637 Response::Clones { clones } => clones,
638 other => return Err(unexpected(other)),
639 };
640 for w in envfile::finish_pending(&home).await {
641 eprintln!("warning: {w}");
642 }
643 if json {
644 print_json(&clones)?;
645 } else {
646 print_clones(&clones);
647 }
648 }
649 Cmd::Top => top(&home).await?,
650 Cmd::Destroy {
651 id_or_name,
652 force,
653 forget,
654 } => {
655 let req = Request::CloneDestroy {
656 id_or_name,
657 force,
658 forget,
659 };
660 match client::request(&home, req).await? {
661 Response::Destroyed { id } => println!("{id}"),
662 other => return Err(unexpected(other)),
663 }
664 for w in envfile::finish_pending(&home).await {
665 eprintln!("warning: {w}");
666 }
667 }
668 Cmd::Wake { id_or_name } => {
669 let req = Request::CloneWake { id_or_name };
670 match client::request(&home, req).await? {
671 Response::Clone(info) => {
672 for w in &info.warnings {
673 eprintln!("warning: {w}");
674 }
675 println!("{}", info.name.as_deref().unwrap_or(&info.id));
676 }
677 other => return Err(unexpected(other)),
678 }
679 }
680 Cmd::Diff {
681 id_or_name,
682 table,
683 limit,
684 json,
685 } => {
686 let req = Request::CloneDiff {
687 id_or_name,
688 table,
689 limit,
690 parent_pid: None,
691 };
692 match client::request(&home, req).await? {
693 Response::Diff(report) if json => print_json(&report)?,
694 Response::Diff(report) => print!("{}", crate::diff::render(&report)),
695 other => return Err(unexpected(other)),
696 }
697 }
698 Cmd::Promote {
699 id_or_name,
700 template,
701 } => {
702 let req = Request::ClonePromote {
703 id_or_name: id_or_name.clone(),
704 template,
705 };
706 match client::request(&home, req).await? {
707 Response::TemplateBuilt(t) => println!(
708 "promoted {id_or_name} to template {} generation {} (Postgres {}, {})",
709 t.name,
710 t.current_gen,
711 t.pg_major,
712 human_bytes(t.size_bytes)
713 ),
714 other => return Err(unexpected(other)),
715 }
716 }
717 Cmd::Zfs {
718 command: ZfsCmd::Setup { dataset },
719 } => return zfs_setup(&dataset),
720 Cmd::Doctor { fix, json } => {
721 let checks = doctor::run(&home, fix).await;
722 if json {
723 print_json(&checks)?;
724 } else {
725 print!("{}", doctor::render(&checks));
726 }
727 return Ok(ExitCode::from(doctor::exit_code(&checks)));
728 }
729 Cmd::Events {
730 follow,
731 clone,
732 template,
733 kind,
734 since,
735 limit,
736 json,
737 } => {
738 let since = match since {
739 Some(d) => Some(now().saturating_sub(parse_duration(&d)?.as_secs() as i64)),
740 None => None,
741 };
742 let filter = EventFilter {
743 clone,
744 template,
745 kind,
746 since,
747 limit,
748 };
749 let show = |e: &EventRow| -> Result<()> {
750 if json {
751 println!("{}", serde_json::to_string(e)?);
752 } else {
753 println!(
754 "{} {} {} {}",
755 events::local_time(e.ts),
756 e.kind,
757 events::row_label(e),
758 events::short_detail(&e.detail)
759 );
760 }
761 Ok(())
762 };
763 if follow {
764 let mut failed = None;
765 client::follow_events(&home, filter, |e| {
766 if failed.is_none() {
767 failed = show(&e).err();
768 }
769 })
770 .await?;
771 if let Some(e) = failed {
772 return Err(e);
773 }
774 } else {
775 match client::request(
776 &home,
777 Request::Events {
778 filter,
779 follow: false,
780 },
781 )
782 .await?
783 {
784 Response::Events { events } if json => print_json(&events)?,
785 Response::Events { events } => {
786 for e in &events {
787 show(e)?;
788 }
789 let cap = limit.unwrap_or(daemon::EVENTS_DEFAULT_LIMIT);
790 if events.len() >= cap as usize {
791 eprintln!(
792 "note: showing only the newest {cap} events; use --limit to see more"
793 );
794 }
795 }
796 other => return Err(unexpected(other)),
797 }
798 }
799 }
800 Cmd::Gc => match client::request(&home, Request::Gc).await? {
801 Response::Gc(r) => {
802 println!(
803 "adopted {}, destroyed {}, orphan folders removed {}, records dropped {}, failed {}",
804 r.adopted.len(),
805 r.destroyed.len(),
806 r.orphan_folders_removed.len(),
807 r.records_dropped.len(),
808 r.failed.len()
809 );
810 for f in &r.failed {
811 println!("failed: {}: {}", f.item, f.error);
812 }
813 }
814 other => return Err(unexpected(other)),
815 },
816 Cmd::Daemon {
817 command: Some(DaemonCmd::Status),
818 } => match client::request_if_running(&home, Request::Status).await? {
819 None => {
820 println!("daemon is not running");
821 return Ok(ExitCode::FAILURE);
822 }
823 Some(Response::Status(s)) => {
824 println!("daemon is running");
825 println!("pid {}", s.pid);
826 println!("version {}", s.version);
827 println!("service {}", s.service);
828 println!("home {}", s.home.display());
829 println!("clones {}", s.clones);
830 println!("templates {}", s.templates);
831 println!(
832 "uptime {}",
833 format_duration(Duration::from_secs(s.uptime_secs))
834 );
835 println!("router {}", router_state(&s.router));
836 if s.router.as_ref().is_some_and(|r| !r.listening) {
837 eprintln!(
838 "warning: the router is not listening; it retries every minute (direct URLs still work: mitosis env <clone> --direct, which wakes a sleeping clone; or mitosis wake <clone>)"
839 );
840 }
841 }
842 Some(other) => return Err(unexpected(other)),
843 },
844 Cmd::Daemon {
845 command: Some(DaemonCmd::Stop),
846 } => {
847 match client::request_if_running(&home, Request::Shutdown).await? {
848 None => println!("daemon is not running"),
849 Some(Response::Ok) => {
850 let deadline = Instant::now() + Duration::from_secs(5);
852 while home.daemon_sock().exists() && Instant::now() < deadline {
853 tokio::time::sleep(Duration::from_millis(50)).await;
854 }
855 println!("daemon stopped");
856 }
857 Some(other) => return Err(unexpected(other)),
858 }
859 }
860 }
861 Ok(ExitCode::SUCCESS)
862}
863
864#[cfg(target_os = "linux")]
867fn zfs_setup(dataset: &str) -> Result<ExitCode> {
868 use crate::clone_backend::zfs;
869 zfs::check_dataset_name(dataset)?;
870 let bin = zfs::which_zfs().unwrap_or_else(|| "/usr/sbin/zfs".into());
871 let mount_root = match zfs::ZfsBackend::new(dataset) {
872 Ok(b) => b.mount_root().to_path_buf(),
873 Err(e) => {
874 let guess = std::path::PathBuf::from(format!("/{dataset}"));
876 eprintln!(
877 "note: could not read the mountpoint of {dataset} ({}); assuming {}",
878 e.message,
879 guess.display()
880 );
881 guess
882 }
883 };
884 let user = zfs::current_user();
885 print!("{}", zfs::setup_text(&user, dataset, &mount_root, &bin));
886 println!();
887 println!("Checks (run this again once the admin is done):");
888 let checks = doctor::zfs_checks(dataset);
889 print!("{}", doctor::render(&checks));
890 Ok(ExitCode::from(doctor::exit_code(&checks)))
891}
892
893#[cfg(not(target_os = "linux"))]
894fn zfs_setup(_dataset: &str) -> Result<ExitCode> {
895 Err(MitosisError::new(
896 ErrorCode::BackendUnsupported,
897 "the zfs back end only works on Linux",
898 )
899 .with_hint("set backend = \"auto\""))
900}
901
902fn print_config(entries: &[ConfigEntry]) {
903 let width = entries.iter().map(|e| e.key.len()).max().unwrap_or(0);
904 let vwidth = entries.iter().map(|e| e.value.len()).max().unwrap_or(0);
905 for e in entries {
906 println!(
907 "{:<width$} = {:<vwidth$} ({})",
908 e.key,
909 e.value,
910 e.source.label()
911 );
912 }
913}
914
915async fn pg(home: &Home, cmd: PgCmd) -> Result<()> {
917 use crate::pg::managed;
918 let h = home.clone();
919 let blocking =
920 |e: tokio::task::JoinError| MitosisError::new(ErrorCode::Internal, e.to_string());
921 match cmd {
922 PgCmd::Install { major, version } => {
923 let src = managed::ReleaseSource::from_env();
924 let i = tokio::task::spawn_blocking(move || {
925 managed::install(&h, major, version.as_deref(), &src)
926 })
927 .await
928 .map_err(blocking)??;
929 println!("{}", i.dir.join("bin").display());
930 }
931 PgCmd::List { json } => {
932 let installs = tokio::task::spawn_blocking(move || managed::list(&h))
933 .await
934 .map_err(blocking)??;
935 if json {
936 print_json(&installs)?;
937 } else if installs.is_empty() {
938 println!("no managed Postgres builds (mitosis pg install <major>)");
939 } else {
940 let rows = installs
941 .iter()
942 .map(|i| {
943 vec![
944 i.version.clone(),
945 if i.used { "yes" } else { "no" }.to_string(),
946 i.dir.display().to_string(),
947 ]
948 })
949 .collect();
950 print_table(&["VERSION", "USED", "PATH"], rows);
951 }
952 }
953 PgCmd::Remove { spec } => {
954 let r = tokio::task::spawn_blocking(move || {
955 managed::remove(&h, &spec, &|bin| managed::used_by_store(&h, bin))
956 })
957 .await
958 .map_err(blocking)??;
959 println!("removed Postgres {}", r.version);
960 }
961 }
962 Ok(())
963}
964
965async fn template(home: &Home, cmd: TemplateCmd) -> Result<()> {
966 match cmd {
967 TemplateCmd::Init {
968 name,
969 from_url,
970 from_running,
971 basebackup,
972 seed,
973 database,
974 migrations,
975 pg_major,
976 settings,
977 } => {
978 validate_name("template", &name)?;
979 let (max_age_secs, settings) = settings.parse()?;
980 let migrations = migrations
981 .map(|dir| {
982 std::fs::canonicalize(&dir).map_err(|e| {
983 MitosisError::new(
984 ErrorCode::InvalidRequest,
985 format!("migrations folder {}: {e}", dir.display()),
986 )
987 })
988 })
989 .transpose()?;
990 let source = match (from_running, from_url, basebackup, seed) {
991 (Some(dataset), _, _, _) => TemplateSource::Running { dataset },
992 (None, Some(url), _, _) => TemplateSource::Url { url },
993 (None, None, Some(url), _) => TemplateSource::Basebackup { url },
994 (None, None, None, Some(command)) => TemplateSource::Seed {
996 command,
997 cwd: Some(std::env::current_dir()?),
998 path: std::env::var("PATH").ok(),
999 },
1000 (None, None, None, None) => {
1001 unreachable!("clap requires --from-url, --from-running, --basebackup or --seed")
1002 }
1003 };
1004 let args = TemplateInitArgs {
1005 name,
1006 source,
1007 database,
1008 migrations,
1009 max_age_secs,
1010 pg_major,
1011 settings,
1012 };
1013 match client::request(home, Request::TemplateInit(args)).await? {
1014 Response::TemplateBuilt(t) => print_built(&t),
1015 other => return Err(unexpected(other)),
1016 }
1017 }
1018 TemplateCmd::Refresh { name, layered } => {
1019 validate_name("template", &name)?;
1020 let path = std::env::var("PATH").ok();
1021 let req = Request::TemplateRefresh {
1022 name,
1023 path,
1024 layered,
1025 };
1026 match client::request(home, req).await? {
1027 Response::TemplateBuilt(t) => print_built(&t),
1028 other => return Err(unexpected(other)),
1029 }
1030 }
1031 TemplateCmd::Set {
1032 name,
1033 settings,
1034 resume,
1035 } => {
1036 validate_name("template", &name)?;
1037 let (max_age_secs, settings) = settings.parse()?;
1038 let args = TemplateSetArgs {
1039 name,
1040 max_age_secs,
1041 settings,
1042 resume,
1043 };
1044 match client::request(home, Request::TemplateSet(args)).await? {
1045 Response::TemplateBuilt(t) => {
1046 println!("template {} settings saved", t.name);
1047 if t.paused {
1048 println!("{}", paused_line(&t.name));
1049 }
1050 }
1051 other => return Err(unexpected(other)),
1052 }
1053 }
1054 TemplateCmd::List { json } => {
1055 let templates = match client::request(home, Request::TemplateList).await? {
1056 Response::Templates { templates } => templates,
1057 other => return Err(unexpected(other)),
1058 };
1059 for t in &templates {
1060 for w in &t.warnings {
1061 eprintln!("warning: {}: {w}", t.name);
1062 }
1063 if let Some(e) = &t.last_error {
1064 eprintln!("last error: {}: {e}", t.name);
1065 }
1066 }
1067 if json {
1068 print_json(&templates)?;
1069 } else {
1070 let is_base = |t: &&TemplateInfo, b: &str| t.base.as_deref() == Some(b);
1072 let mut ordered: Vec<&TemplateInfo> = Vec::new();
1073 for t in templates.iter().filter(|t| t.base.is_none()) {
1074 ordered.push(t);
1075 ordered.extend(templates.iter().filter(|b| is_base(b, &t.name)));
1076 }
1077 ordered.extend(templates.iter().filter(|t| {
1078 t.base
1079 .as_deref()
1080 .is_some_and(|b| !templates.iter().any(|x| x.name == b))
1081 }));
1082 print_table(
1083 &[
1084 "NAME", "GEN", "PAUSED", "PG", "DATABASE", "SIZE", "AGE", "SOURCE",
1085 ],
1086 ordered
1087 .into_iter()
1088 .map(|t| {
1089 vec![
1090 if t.base.is_some() {
1091 format!(" {}", t.name)
1092 } else {
1093 t.name.clone()
1094 },
1095 format!("{}/{}", t.current_gen, t.generations),
1096 if t.paused { "paused" } else { "-" }.to_string(),
1097 t.pg_major.to_string(),
1098 t.database.clone(),
1099 human_bytes(t.size_bytes),
1100 format_duration(Duration::from_secs(t.age_secs)),
1101 source_cell(&t.source),
1102 ]
1103 })
1104 .collect(),
1105 );
1106 }
1107 }
1108 TemplateCmd::History { name, json } => {
1109 crate::templates::validate_template_ref(&name)?;
1110 let (template, generations, paused) =
1111 match client::request(home, Request::TemplateHistory { name }).await? {
1112 Response::Generations {
1113 template,
1114 generations,
1115 paused,
1116 } => (template, generations, paused),
1117 other => return Err(unexpected(other)),
1118 };
1119 if json {
1120 print_json(&history_json(&template, paused, &generations))?;
1121 } else {
1122 if paused {
1123 println!("{}", paused_line(&template));
1124 }
1125 let now = now();
1126 print_table(
1127 &["GEN", "KIND", "BUILT", "PG", "SIZE", "CURRENT"],
1128 generations
1129 .iter()
1130 .map(|g| {
1131 let ago = u64::try_from(now - g.built_at).unwrap_or(0);
1132 vec![
1133 g.gen.to_string(),
1134 serde_json::to_value(g.kind)
1135 .ok()
1136 .and_then(|v| v.as_str().map(String::from))
1137 .unwrap_or_default(),
1138 format!("{} ago", format_duration(Duration::from_secs(ago))),
1139 g.pg_major.to_string(),
1140 human_bytes(g.size_bytes),
1141 if g.current { "*" } else { "" }.to_string(),
1142 ]
1143 })
1144 .collect(),
1145 );
1146 }
1147 }
1148 TemplateCmd::Rollback { name, to } => {
1149 validate_name("template", &name)?;
1150 match client::request(home, Request::TemplateRollback { name, to }).await? {
1151 Response::TemplateBuilt(t) => println!(
1152 "template {} now uses generation {} (scheduled refreshes paused)",
1153 t.name, t.current_gen
1154 ),
1155 other => return Err(unexpected(other)),
1156 }
1157 }
1158 TemplateCmd::Upgrade { name, to, as_name } => {
1159 validate_name("template", &name)?;
1160 if let Some(n) = &as_name {
1161 validate_name("template", n)?;
1162 }
1163 let req = Request::TemplateUpgrade {
1164 name: name.clone(),
1165 to,
1166 as_name,
1167 };
1168 match client::request(home, req).await? {
1169 Response::TemplateBuilt(t) => println!(
1170 "upgraded {name} to Postgres {}: template {} generation {} ({})",
1171 t.pg_major,
1172 t.name,
1173 t.current_gen,
1174 human_bytes(t.size_bytes)
1175 ),
1176 other => return Err(unexpected(other)),
1177 }
1178 }
1179 }
1180 Ok(())
1181}
1182
1183pub fn history_json(
1185 template: &str,
1186 paused: bool,
1187 generations: &[crate::protocol::GenerationInfo],
1188) -> serde_json::Value {
1189 serde_json::json!({
1190 "template": template,
1191 "paused": paused,
1192 "generations": generations,
1193 })
1194}
1195
1196pub fn command() -> clap::Command {
1198 <Cli as clap::CommandFactory>::command()
1199}
1200
1201fn source_cell(source: &TemplateSource) -> String {
1204 const MAX: usize = 48;
1205 let text = match source {
1206 TemplateSource::Url { url } => url.clone(),
1207 TemplateSource::Basebackup { url } => format!("basebackup: {url}"),
1208 TemplateSource::Seed { command, .. } => {
1209 format!(
1210 "seed: {}",
1211 command.split_whitespace().collect::<Vec<_>>().join(" ")
1212 )
1213 }
1214 TemplateSource::Promoted { clone_id } => format!("promoted from {clone_id}"),
1215 TemplateSource::Running { dataset } => format!("running: {dataset}"),
1216 };
1217 if text.chars().count() <= MAX {
1218 return text;
1219 }
1220 let cut: String = text.chars().take(MAX - 1).collect();
1221 format!("{cut}…")
1222}
1223
1224fn paused_line(name: &str) -> String {
1225 format!(
1226 "paused: automatic builds stopped — run \"mitosis template refresh {name}\" or \"mitosis template set {name} --resume\""
1227 )
1228}
1229
1230fn print_built(t: &TemplateInfo) {
1231 println!(
1232 "template {} built (Postgres {}, {})",
1233 t.name,
1234 t.pg_major,
1235 human_bytes(t.size_bytes)
1236 );
1237}
1238
1239fn print_clones(clones: &[CloneInfo]) {
1240 let now = now();
1241 let secs = |s: i64| format_duration(Duration::from_secs(u64::try_from(s).unwrap_or(0)));
1242 let opt_bytes = |b: Option<u64>| b.map_or("-".to_string(), human_bytes);
1243 print_table(
1244 &[
1245 "ID",
1246 "NAME",
1247 "TEMPLATE",
1248 "PORT",
1249 "URL",
1250 "AGE",
1251 "TTL LEFT",
1252 "RAM",
1253 "EXTRA DISK",
1254 "FLAGS",
1255 ],
1256 clones
1257 .iter()
1258 .map(|c| {
1259 vec![
1260 c.id.clone(),
1261 c.name.clone().unwrap_or_else(|| "-".into()),
1262 c.template.clone(),
1263 c.port.to_string(),
1264 crate::templates::redact_url(&c.url),
1266 secs(now - c.created_at),
1267 if c.protected {
1268 "never".into()
1269 } else {
1270 secs(c.expires_at - now)
1271 },
1272 opt_bytes(c.rss_bytes),
1273 opt_bytes(c.own_disk_bytes),
1274 flags(c),
1275 ]
1276 })
1277 .collect(),
1278 );
1279}
1280
1281fn flags(c: &CloneInfo) -> String {
1283 let mut f = Vec::new();
1284 if c.status == "sleeping" {
1285 f.push("sleeping");
1286 }
1287 if c.disk_warn {
1288 f.push("!");
1289 }
1290 if c.protected {
1291 f.push("protected");
1292 }
1293 f.join(" ")
1294}
1295
1296struct RawTerminal {
1299 saved: nix::sys::termios::Termios,
1300}
1301
1302impl RawTerminal {
1303 fn enter() -> Result<RawTerminal> {
1304 use nix::sys::termios::{
1305 tcgetattr, tcsetattr, LocalFlags, SetArg, SpecialCharacterIndices as C,
1306 };
1307 let stdin = std::io::stdin();
1308 let saved = tcgetattr(&stdin).map_err(|_| {
1309 MitosisError::new(ErrorCode::InvalidRequest, "mitosis top needs a terminal")
1310 .with_hint("run it in a terminal, or use `mitosis list --json`")
1311 })?;
1312 let mut raw = saved.clone();
1313 raw.local_flags
1315 .remove(LocalFlags::ICANON | LocalFlags::ECHO | LocalFlags::ISIG);
1316 raw.control_chars[C::VMIN as usize] = 0;
1318 raw.control_chars[C::VTIME as usize] = 20;
1319 tcsetattr(&stdin, SetArg::TCSANOW, &raw)
1320 .map_err(|e| MitosisError::new(ErrorCode::Internal, format!("terminal: {e}")))?;
1321 let term = RawTerminal { saved };
1322 print!("\x1b[?1049h\x1b[?25l");
1323 let _ = std::io::Write::flush(&mut std::io::stdout());
1324 Ok(term)
1325 }
1326}
1327
1328impl Drop for RawTerminal {
1329 fn drop(&mut self) {
1330 let _ = nix::sys::termios::tcsetattr(
1331 std::io::stdin(),
1332 nix::sys::termios::SetArg::TCSANOW,
1333 &self.saved,
1334 );
1335 print!("\x1b[?25h\x1b[?1049l");
1336 let _ = std::io::Write::flush(&mut std::io::stdout());
1337 }
1338}
1339
1340fn quit_key() -> bool {
1343 use std::io::Read;
1344 let mut b = [0u8; 64];
1345 matches!(std::io::stdin().read(&mut b), Ok(n) if asks_to_quit(&b[..n]))
1346}
1347
1348fn asks_to_quit(bytes: &[u8]) -> bool {
1350 bytes.iter().any(|b| matches!(b, b'q' | b'Q' | 3))
1351}
1352
1353pub fn router_state(r: &Option<crate::protocol::RouterStatus>) -> String {
1355 match r {
1356 None => "off".into(),
1357 Some(r) => {
1358 let host = if r.listen.is_empty() {
1359 "127.0.0.1"
1360 } else {
1361 &r.listen
1362 };
1363 match &r.error {
1364 None if r.listening => format!("listening on {host}:{}", r.port),
1365 None => format!("not listening on {host}:{}", r.port),
1366 Some(e) => format!("not listening: {e}"),
1367 }
1368 }
1369 }
1370}
1371
1372fn draw_top(u: &budget::Usage, router: &Option<crate::protocol::RouterStatus>) {
1373 let now = now();
1374 let secs = |s: i64| format_duration(Duration::from_secs(u64::try_from(s).unwrap_or(0)));
1375 let opt_bytes = |b: Option<u64>| b.map_or("-".to_string(), human_bytes);
1376 let of = |used: String, cap: Option<u64>| match cap {
1377 Some(c) => format!("{used} / {}", human_bytes(c)),
1378 None => format!("{used} / no limit"),
1379 };
1380 print!("\x1b[H\x1b[2J");
1381 println!("mitosis top q to quit, refreshes every 2s\n");
1382 print_table(
1383 &[
1384 "ID",
1385 "NAME",
1386 "TEMPLATE",
1387 "STATUS",
1388 "TTL LEFT",
1389 "RAM",
1390 "EXTRA DISK",
1391 "FLAGS",
1392 ],
1393 u.clones
1394 .iter()
1395 .map(|c| {
1396 vec![
1397 c.id.clone(),
1398 c.name.clone().unwrap_or_else(|| "-".into()),
1399 c.template.clone(),
1400 c.status.clone(),
1401 if c.protected {
1402 "never".into()
1403 } else {
1404 secs(c.expires_at - now)
1405 },
1406 if c.status == "sleeping" {
1408 human_bytes(0)
1409 } else {
1410 opt_bytes(c.rss_bytes)
1411 },
1412 opt_bytes(c.own_disk_bytes),
1413 [(c.disk_warn, "!"), (c.protected, "protected")]
1414 .iter()
1415 .filter(|(on, _)| *on)
1416 .map(|(_, t)| *t)
1417 .collect::<Vec<_>>()
1418 .join(" "),
1419 ]
1420 })
1421 .collect(),
1422 );
1423 println!(
1424 "\nclones {} / {}\nram {}\ndisk {}\nfree {}\nqueued {}\nrouter {}",
1425 u.clones.len(),
1426 u.max_clones,
1427 of(human_bytes(u.ram_bytes), u.ram_budget),
1428 of(human_bytes(u.disk_bytes), u.disk_budget),
1429 human_bytes(u.free_disk_bytes),
1430 u.queued,
1431 router_state(router)
1432 );
1433 let _ = std::io::Write::flush(&mut std::io::stdout());
1434}
1435
1436async fn top(home: &Home) -> Result<()> {
1437 use tokio::signal::unix::{signal, SignalKind};
1438 let terminal = RawTerminal::enter()?;
1439 let sig = |k: SignalKind| {
1442 signal(k).map_err(|e| MitosisError::new(ErrorCode::Internal, format!("signals: {e}")))
1443 };
1444 let mut term = sig(SignalKind::terminate())?;
1445 let mut hup = sig(SignalKind::hangup())?;
1446 let mut int = sig(SignalKind::interrupt())?;
1447 let signo = tokio::select! {
1448 r = top_loop(home) => return r,
1449 _ = term.recv() => libc::SIGTERM,
1450 _ = hup.recv() => libc::SIGHUP,
1451 _ = int.recv() => libc::SIGINT,
1452 };
1453 drop(terminal);
1454 std::process::exit(128 + signo);
1457}
1458
1459async fn top_loop(home: &Home) -> Result<()> {
1461 loop {
1462 let router = match client::request(home, Request::Status).await? {
1463 Response::Status(s) => s.router,
1464 other => return Err(unexpected(other)),
1465 };
1466 match client::request(home, Request::Usage).await? {
1467 Response::Usage(u) => draw_top(&u, &router),
1468 other => return Err(unexpected(other)),
1469 }
1470 let quit = tokio::task::spawn_blocking(quit_key)
1471 .await
1472 .map_err(|e| MitosisError::new(ErrorCode::Internal, format!("terminal: {e}")))?;
1473 if quit {
1474 return Ok(());
1475 }
1476 }
1477}
1478
1479fn print_table(headers: &[&str], rows: Vec<Vec<String>>) {
1481 let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
1482 for row in &rows {
1483 for (w, cell) in widths.iter_mut().zip(row) {
1484 *w = (*w).max(cell.chars().count());
1485 }
1486 }
1487 let line = |cells: Vec<&str>| {
1488 let parts: Vec<String> = cells
1489 .iter()
1490 .zip(&widths)
1491 .map(|(c, w)| format!("{c:<w$}"))
1492 .collect();
1493 println!("{}", parts.join(" ").trim_end());
1494 };
1495 line(headers.to_vec());
1496 for row in &rows {
1497 line(row.iter().map(String::as_str).collect());
1498 }
1499}
1500
1501fn human_bytes(b: u64) -> String {
1503 if b < 1000 {
1504 return format!("{b} B");
1505 }
1506 let mut v = b as f64;
1507 let mut unit = "B";
1508 for u in ["kB", "MB", "GB", "TB", "PB"] {
1509 if v < 1000.0 {
1510 break;
1511 }
1512 v /= 1000.0;
1513 unit = u;
1514 }
1515 if v < 10.0 {
1516 format!("{v:.1} {unit}")
1517 } else {
1518 format!("{v:.0} {unit}")
1519 }
1520}
1521
1522#[cfg(test)]
1523mod tests {
1524 use super::*;
1525 use clap::CommandFactory;
1526
1527 #[test]
1528 fn clap_definition_is_valid() {
1529 Cli::command().debug_assert();
1530 }
1531
1532 #[test]
1533 fn sizes() {
1534 assert_eq!(human_bytes(0), "0 B");
1535 assert_eq!(human_bytes(999), "999 B");
1536 assert_eq!(human_bytes(3_400), "3.4 kB");
1537 assert_eq!(human_bytes(34_000_000), "34 MB");
1538 assert_eq!(human_bytes(1_200_000_000), "1.2 GB");
1539 }
1540
1541 #[test]
1542 fn source_cells_are_short() {
1543 let url = TemplateSource::Url {
1544 url: "postgres://u:***@h/db".into(),
1545 };
1546 assert_eq!(source_cell(&url), "postgres://u:***@h/db");
1547 let seed = TemplateSource::Seed {
1548 command: format!("psql \"$DATABASE_URL\"\n -f {}", "x".repeat(80)),
1549 cwd: None,
1550 path: None,
1551 };
1552 let cell = source_cell(&seed);
1553 assert!(
1554 cell.starts_with("seed: psql \"$DATABASE_URL\" -f xx"),
1555 "{cell}"
1556 );
1557 assert_eq!(cell.chars().count(), 48);
1558 assert!(cell.ends_with('…') && !cell.contains('\n'));
1559 }
1560
1561 #[test]
1562 fn init_needs_exactly_one_source() {
1563 assert!(Cli::try_parse_from(["mitosis", "template", "init"]).is_err());
1564 assert!(Cli::try_parse_from([
1565 "mitosis",
1566 "template",
1567 "init",
1568 "--seed",
1569 "x",
1570 "--from-url",
1571 "y"
1572 ])
1573 .is_err());
1574 assert!(Cli::try_parse_from(["mitosis", "template", "init", "--seed", "x"]).is_ok());
1575 let basebackup = |extra: &[&str]| {
1576 let mut args = vec!["mitosis", "template", "init", "--basebackup", "u"];
1577 args.extend_from_slice(extra);
1578 Cli::try_parse_from(args)
1579 };
1580 assert!(basebackup(&[]).is_ok());
1581 assert!(basebackup(&["--database", "shop"]).is_ok());
1582 assert!(basebackup(&["--seed", "x"]).is_err());
1583 assert!(basebackup(&["--from-url", "y"]).is_err());
1584 let running = |extra: &[&str]| {
1585 let mut args = vec!["mitosis", "template", "init", "--from-running", "tank/pg"];
1586 args.extend_from_slice(extra);
1587 Cli::try_parse_from(args)
1588 };
1589 assert!(running(&[]).is_ok());
1590 assert!(running(&["--database", "shop"]).is_ok());
1591 for other in ["--seed", "--from-url", "--basebackup"] {
1592 assert!(running(&[other, "x"]).is_err(), "{other}");
1593 }
1594 }
1595
1596 #[test]
1597 fn settings_flags_parse() {
1598 let cli = Cli::try_parse_from([
1599 "mitosis",
1600 "template",
1601 "set",
1602 "main",
1603 "--keep",
1604 "2",
1605 "--every",
1606 "off",
1607 "--every-mode",
1608 "layered",
1609 "--migrate",
1610 "make migrate",
1611 "--auto-migrate",
1612 "on",
1613 "--on-stale",
1614 "refresh",
1615 "--max-age",
1616 "1d",
1617 "--resume",
1618 ])
1619 .unwrap();
1620 let Cmd::Template {
1621 command: TemplateCmd::Set {
1622 settings, resume, ..
1623 },
1624 } = cli.command
1625 else {
1626 panic!("wrong command")
1627 };
1628 assert!(resume);
1629 let (max_age, s) = settings.parse().unwrap();
1630 assert_eq!(max_age, Some(86_400));
1631 assert_eq!(s.keep, Some(2));
1632 assert!(s.every_off && s.every_secs.is_none());
1633 assert_eq!(s.every_mode, Some(crate::protocol::RefreshMode::Layered));
1634 assert_eq!(s.migrate.unwrap().command, "make migrate");
1635 assert_eq!(s.auto_migrate, Some(true));
1636 assert_eq!(s.on_stale, Some(crate::protocol::OnStale::Refresh));
1637 assert!(
1638 Cli::try_parse_from(["mitosis", "template", "set", "--every-mode", "fast"]).is_err()
1639 );
1640 }
1641
1642 #[test]
1643 fn top_reads_a_whole_key_at_once() {
1644 assert!(asks_to_quit(b"q") && asks_to_quit(b"Q") && asks_to_quit(&[3]));
1645 assert!(!asks_to_quit(b"\x1b[A"));
1647 assert!(
1648 asks_to_quit(b"\x1b[Aq"),
1649 "a q behind other keys still quits"
1650 );
1651 assert!(!asks_to_quit(b""));
1652 }
1653}