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