systemprompt_cli/commands/cloud/db/
mod.rs1mod backup;
11mod restore;
12
13use anyhow::{Context, Result, anyhow, bail};
14use clap::{Subcommand, ValueEnum};
15use std::path::PathBuf;
16use std::process::Command;
17use systemprompt_cloud::{ProfilePath, ProjectContext};
18use systemprompt_runtime::DatabaseContext;
19
20use crate::commands::infrastructure::db;
21use crate::context::CommandContext;
22
23#[derive(Debug, Subcommand)]
24pub enum CloudDbCommands {
25 #[command(about = "Run migrations on cloud database")]
26 Migrate {
27 #[arg(long, help = "Profile name")]
28 profile: String,
29 },
30
31 #[command(about = "Execute SQL query (read-only) on cloud database")]
32 Query {
33 #[arg(long, help = "Profile name")]
34 profile: String,
35 sql: String,
36 #[arg(long)]
37 limit: Option<u32>,
38 #[arg(long)]
39 offset: Option<u32>,
40 },
41
42 #[command(about = "Execute write operation on cloud database")]
43 Execute {
44 #[arg(long, help = "Profile name")]
45 profile: String,
46 sql: String,
47 },
48
49 #[command(about = "Diff cloud schema against extension declarations")]
50 Doctor {
51 #[arg(long, help = "Profile name")]
52 profile: String,
53 },
54
55 #[command(about = "Show cloud database connection status")]
56 Status {
57 #[arg(long, help = "Profile name")]
58 profile: String,
59 },
60
61 #[command(about = "Show cloud database info")]
62 Info {
63 #[arg(long, help = "Profile name")]
64 profile: String,
65 },
66
67 #[command(about = "List all tables in cloud database")]
68 Tables {
69 #[arg(long, help = "Profile name")]
70 profile: String,
71 #[arg(long, help = "Filter tables by pattern")]
72 filter: Option<String>,
73 },
74
75 #[command(about = "Describe table schema in cloud database")]
76 Describe {
77 #[arg(long, help = "Profile name")]
78 profile: String,
79 table_name: String,
80 },
81
82 #[command(about = "Get row count for a table in cloud database")]
83 Count {
84 #[arg(long, help = "Profile name")]
85 profile: String,
86 table_name: String,
87 },
88
89 #[command(about = "List all indexes in cloud database")]
90 Indexes {
91 #[arg(long, help = "Profile name")]
92 profile: String,
93 #[arg(long, help = "Filter by table name")]
94 table: Option<String>,
95 },
96
97 #[command(about = "Show cloud database and table sizes")]
98 Size {
99 #[arg(long, help = "Profile name")]
100 profile: String,
101 },
102
103 #[command(about = "Backup cloud database using pg_dump")]
104 Backup {
105 #[arg(long, help = "Profile name")]
106 profile: String,
107
108 #[arg(
109 long,
110 default_value = "custom",
111 help = "Backup format: custom, sql, directory"
112 )]
113 format: BackupFormat,
114
115 #[arg(
116 long,
117 help = "Output file path (default: backups/<profile>-<timestamp>.<ext>)"
118 )]
119 output: Option<String>,
120 },
121
122 #[command(about = "Restore cloud database from a backup file")]
123 Restore {
124 #[arg(long, help = "Profile name")]
125 profile: String,
126
127 #[arg(help = "Path to backup file")]
128 file: String,
129
130 #[arg(short = 'y', long, help = "Skip confirmation prompt")]
131 yes: bool,
132 },
133}
134
135#[derive(Debug, Clone, Copy, ValueEnum)]
136pub enum BackupFormat {
137 #[value(help = "pg_dump custom format (-Fc), supports parallel restore")]
138 Custom,
139 #[value(help = "Plain SQL text format (-Fp), human-readable")]
140 Sql,
141 #[value(help = "Directory format (-Fd), supports parallel dump and restore")]
142 Directory,
143}
144
145impl CloudDbCommands {
146 fn profile_name(&self) -> &str {
147 match self {
148 Self::Migrate { profile }
149 | Self::Query { profile, .. }
150 | Self::Execute { profile, .. }
151 | Self::Doctor { profile }
152 | Self::Status { profile }
153 | Self::Info { profile }
154 | Self::Tables { profile, .. }
155 | Self::Describe { profile, .. }
156 | Self::Count { profile, .. }
157 | Self::Indexes { profile, .. }
158 | Self::Size { profile }
159 | Self::Backup { profile, .. }
160 | Self::Restore { profile, .. } => profile,
161 }
162 }
163
164 fn into_db_command(self) -> Option<db::DbCommands> {
165 match self {
166 Self::Migrate { .. } => Some(db::DbCommands::Migrate {
167 allow_checksum_drift: false,
168 }),
169 Self::Query {
170 sql, limit, offset, ..
171 } => Some(db::DbCommands::Query { sql, limit, offset }),
172 Self::Execute { sql, .. } => Some(db::DbCommands::Execute { sql }),
173 Self::Doctor { .. } => Some(db::DbCommands::Doctor),
174 Self::Status { .. } => Some(db::DbCommands::Status),
175 Self::Info { .. } => Some(db::DbCommands::Info),
176 Self::Tables { filter, .. } => Some(db::DbCommands::Tables { filter }),
177 Self::Describe { table_name, .. } => Some(db::DbCommands::Describe { table_name }),
178 Self::Count { table_name, .. } => Some(db::DbCommands::Count { table_name }),
179 Self::Indexes { table, .. } => Some(db::DbCommands::Indexes { table }),
180 Self::Size { .. } => Some(db::DbCommands::Size),
181 Self::Backup { .. } | Self::Restore { .. } => None,
182 }
183 }
184}
185
186pub async fn execute(cmd: CloudDbCommands, ctx: &CommandContext) -> Result<()> {
187 let profile_name = cmd.profile_name().to_owned();
188 let db_url = load_cloud_database_url(&profile_name)?;
189 execute_inner(cmd, &profile_name, &db_url, ctx).await
190}
191
192pub async fn execute_with_database_url(
193 cmd: CloudDbCommands,
194 database_url: &str,
195 ctx: &CommandContext,
196) -> Result<()> {
197 let profile_name = cmd.profile_name().to_owned();
198 execute_inner(cmd, &profile_name, database_url, ctx).await
199}
200
201async fn execute_inner(
202 cmd: CloudDbCommands,
203 profile_name: &str,
204 db_url: &str,
205 ctx: &CommandContext,
206) -> Result<()> {
207 match &cmd {
208 CloudDbCommands::Backup { format, output, .. } => {
209 return backup::execute(profile_name, db_url, *format, output.as_deref());
210 },
211 CloudDbCommands::Restore { file, yes, .. } => {
212 return restore::execute(profile_name, db_url, file, *yes, ctx.prompter(), &ctx.cli);
213 },
214 _ => {},
215 }
216
217 let db_ctx = DatabaseContext::from_url(db_url).await?;
218 let db_cmd = cmd
219 .into_db_command()
220 .ok_or_else(|| anyhow!("Unexpected command variant"))?;
221
222 let db_scoped =
223 CommandContext::with_database(ctx.cli.clone(), ctx.env.clone(), db_ctx, db_url.to_owned());
224 db::execute(db_cmd, &db_scoped).await
225}
226
227fn load_cloud_database_url(profile_name: &str) -> Result<String> {
228 let ctx = ProjectContext::discover();
229 let profile_dir = ctx.profile_dir(profile_name);
230
231 if !profile_dir.exists() {
232 return Err(anyhow!("Profile '{}' not found", profile_name));
233 }
234
235 let secrets_path = ProfilePath::Secrets.resolve(&profile_dir);
236 let secrets = systemprompt_config::load_secrets_from_path(&secrets_path)
237 .with_context(|| format!("Failed to load secrets for profile '{}'", profile_name))?;
238
239 Ok(secrets.effective_database_url(true).to_owned())
240}
241
242fn ensure_pg_tool(tool: &str) -> Result<()> {
243 match Command::new(tool).arg("--version").output() {
244 Ok(output) if output.status.success() => Ok(()),
245 _ => bail!(
246 "'{}' not found. Install PostgreSQL client tools:\n apt install postgresql-client",
247 tool
248 ),
249 }
250}
251
252fn find_pg_dump() -> Result<PathBuf> {
253 for version in [17, 16, 15, 14] {
254 let path = PathBuf::from(format!("/usr/lib/postgresql/{}/bin/pg_dump", version));
255 if path.exists() {
256 return Ok(path);
257 }
258 }
259 ensure_pg_tool("pg_dump")?;
260 Ok(PathBuf::from("pg_dump"))
261}
262
263fn find_pg_restore() -> Result<PathBuf> {
264 for version in [17, 16, 15, 14] {
265 let path = PathBuf::from(format!("/usr/lib/postgresql/{}/bin/pg_restore", version));
266 if path.exists() {
267 return Ok(path);
268 }
269 }
270 ensure_pg_tool("pg_restore")?;
271 Ok(PathBuf::from("pg_restore"))
272}
273
274fn adjust_ssl_mode(database_url: &str) -> String {
275 database_url.replace("sslmode=require", "sslmode=prefer")
276}