Skip to main content

systemprompt_cli/commands/admin/setup/
postgres.rs

1//! `PostgreSQL` provisioning steps for the setup wizard.
2//!
3//! Resolves connection parameters from [`SetupArgs`], reaches an existing
4//! server or starts one via Docker, and—when given superuser
5//! credentials—creates the role, database, and grants. The bootstrap `CREATE
6//! USER`/`CREATE DATABASE`/ `GRANT` statements use dynamic SQL because they run
7//! before the target database exists and cannot bind parameters.
8
9use anyhow::{Context, Result, anyhow};
10use sqlx::postgres::PgPoolOptions;
11use std::time::Duration;
12use systemprompt_logging::CliService;
13
14use super::SetupArgs;
15use super::common::{
16    PostgresConfig, detect_postgresql, enable_extensions, generate_password, test_connection,
17};
18use crate::CliConfig;
19use crate::interactive::Prompter;
20
21pub async fn setup_non_interactive(
22    args: &SetupArgs,
23    env_name: &str,
24    cli_config: &CliConfig,
25) -> Result<PostgresConfig> {
26    if !cli_config.is_json_output() {
27        CliService::section(&format!("PostgreSQL Setup ({})", env_name));
28    }
29
30    let password = args.db_password.clone().unwrap_or_else(generate_password);
31    let config = PostgresConfig {
32        host: args.db_host.clone(),
33        port: args.db_port,
34        user: args.effective_db_user(env_name),
35        password,
36        database: args.effective_db_name(env_name),
37    };
38
39    if !cli_config.is_json_output() {
40        CliService::key_value("Host", &config.host);
41        CliService::key_value("Port", &config.port.to_string());
42        CliService::key_value("User", &config.user);
43        CliService::key_value("Database", &config.database);
44    }
45
46    if args.docker {
47        if !cli_config.is_json_output() {
48            CliService::info("Setting up PostgreSQL with Docker...");
49        }
50        return super::docker::setup_docker_postgres_non_interactive(&config, env_name).await;
51    }
52
53    if detect_postgresql(&config.host, config.port) {
54        if !cli_config.is_json_output() {
55            CliService::success(&format!(
56                "PostgreSQL reachable at {}:{}",
57                config.host, config.port
58            ));
59        }
60    } else if !cli_config.is_json_output() {
61        CliService::warning(&format!(
62            "PostgreSQL not reachable at {}:{}",
63            config.host, config.port
64        ));
65        CliService::info("Continuing with provided configuration...");
66    }
67
68    if test_connection(&config).await {
69        if !cli_config.is_json_output() {
70            CliService::success("Database connection successful");
71        }
72        enable_extensions(&config).await?;
73    } else if !cli_config.is_json_output() {
74        CliService::warning("Cannot connect to database - it may need to be created manually");
75    }
76
77    Ok(config)
78}
79
80pub async fn setup_interactive(
81    args: &SetupArgs,
82    prompter: &dyn Prompter,
83    env_name: &str,
84    _cli_config: &CliConfig,
85) -> Result<PostgresConfig> {
86    CliService::section(&format!("PostgreSQL Setup ({})", env_name));
87    CliService::info("Configure PostgreSQL database for your local environment.");
88
89    let options = vec![
90        "Use existing PostgreSQL installation".to_owned(),
91        "Start PostgreSQL with Docker".to_owned(),
92    ];
93
94    let selection = prompter.select("How would you like to set up PostgreSQL?", &options)?;
95
96    match selection {
97        0 => setup_existing_postgres(args, prompter, env_name).await,
98        1 => super::docker::setup_docker_postgres_interactive(args, prompter, env_name).await,
99        _ => Err(anyhow!("Invalid PostgreSQL setup option selected")),
100    }
101}
102
103async fn setup_existing_postgres(
104    args: &SetupArgs,
105    prompter: &dyn Prompter,
106    env_name: &str,
107) -> Result<PostgresConfig> {
108    CliService::info("Configuring existing PostgreSQL connection...");
109
110    let (host, port) = prompt_host_port(args, prompter)?;
111
112    let user = prompter.input_with_default("Database user", &args.effective_db_user(env_name))?;
113
114    let password = prompt_password(args, prompter)?;
115
116    let database =
117        prompter.input_with_default("Database name", &args.effective_db_name(env_name))?;
118
119    let config = PostgresConfig {
120        host,
121        port,
122        user,
123        password,
124        database,
125    };
126
127    verify_or_create_database(&config, prompter).await?;
128
129    Ok(config)
130}
131
132fn prompt_host_port(args: &SetupArgs, prompter: &dyn Prompter) -> Result<(String, u16)> {
133    let host = prompter.input_with_default("PostgreSQL host", &args.db_host)?;
134
135    let port_input = prompter.input_with_default("PostgreSQL port", &args.db_port.to_string())?;
136    let port: u16 = port_input
137        .trim()
138        .parse()
139        .with_context(|| format!("Invalid PostgreSQL port: {}", port_input))?;
140
141    if detect_postgresql(&host, port) {
142        CliService::success(&format!("PostgreSQL reachable at {}:{}", host, port));
143    } else {
144        CliService::warning(&format!("Cannot reach PostgreSQL at {}:{}", host, port));
145        let continue_anyway = prompter.confirm("Continue anyway?", false)?;
146
147        if !continue_anyway {
148            anyhow::bail!("PostgreSQL not reachable. Please start PostgreSQL and try again.");
149        }
150    }
151
152    Ok((host, port))
153}
154
155fn prompt_password(args: &SetupArgs, prompter: &dyn Prompter) -> Result<String> {
156    let password = if let Some(ref pw) = args.db_password {
157        pw.clone()
158    } else {
159        let use_generated = prompter.confirm("Generate a secure password automatically?", true)?;
160
161        if use_generated {
162            let generated = generate_password();
163            CliService::success(&format!("Generated password: {}", generated));
164            generated
165        } else {
166            prompter.password("Database password")?
167        }
168    };
169
170    if password.is_empty() {
171        anyhow::bail!("Password is required");
172    }
173
174    Ok(password)
175}
176
177async fn verify_or_create_database(config: &PostgresConfig, prompter: &dyn Prompter) -> Result<()> {
178    if test_connection(config).await {
179        CliService::success("Successfully connected to database!");
180        enable_extensions(config).await?;
181        return Ok(());
182    }
183
184    CliService::warning("Cannot connect with provided credentials.");
185    CliService::info("The database or user may not exist yet.");
186
187    let create_db = prompter.confirm("Create database and user now? (requires superuser)", true)?;
188
189    if create_db {
190        create_database_interactive(config, prompter).await?;
191        enable_extensions(config).await?;
192    } else {
193        CliService::warning("Skipping database creation. You may need to create it manually.");
194    }
195
196    Ok(())
197}
198
199async fn create_database_interactive(
200    config: &PostgresConfig,
201    prompter: &dyn Prompter,
202) -> Result<()> {
203    CliService::info("Enter PostgreSQL superuser credentials (typically 'postgres'):");
204
205    let superuser = prompter.input_with_default("Superuser name", "postgres")?;
206
207    let superpass = prompter.password("Superuser password")?;
208
209    if superpass.is_empty() {
210        anyhow::bail!("Superuser password is required");
211    }
212
213    let super_url = format!(
214        "postgres://{}:{}@{}:{}/postgres",
215        superuser, superpass, config.host, config.port
216    );
217
218    let pool = PgPoolOptions::new()
219        .max_connections(1)
220        .acquire_timeout(Duration::from_secs(5))
221        .connect(&super_url)
222        .await
223        .context("Failed to connect with superuser credentials")?;
224
225    let user_exists: bool = sqlx::query_scalar!(
226        "SELECT EXISTS(SELECT 1 FROM pg_roles WHERE rolname = $1)",
227        &config.user
228    )
229    .fetch_one(&pool)
230    .await?
231    .unwrap_or(false);
232
233    if !user_exists {
234        CliService::info(&format!("Creating user '{}'...", config.user));
235        let create_user_sql = super::ddl::build_create_user_sql(&config.user, &config.password);
236        sqlx::query(sqlx::AssertSqlSafe(create_user_sql))
237            .execute(&pool)
238            .await?;
239        CliService::success(&format!("Created user '{}'", config.user));
240    }
241
242    let db_exists: bool = sqlx::query_scalar!(
243        "SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)",
244        &config.database
245    )
246    .fetch_one(&pool)
247    .await?
248    .unwrap_or(false);
249
250    if !db_exists {
251        CliService::info(&format!("Creating database '{}'...", config.database));
252        let create_db_sql = super::ddl::build_create_db_sql(&config.database, &config.user);
253        sqlx::query(sqlx::AssertSqlSafe(create_db_sql))
254            .execute(&pool)
255            .await?;
256        CliService::success(&format!("Created database '{}'", config.database));
257    }
258
259    let grant_sql = super::ddl::build_grant_sql(&config.database, &config.user);
260    sqlx::query(sqlx::AssertSqlSafe(grant_sql))
261        .execute(&pool)
262        .await?;
263
264    pool.close().await;
265
266    CliService::success("Database and user setup complete");
267    Ok(())
268}