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