From 6d16748cb3a34a37bfa19527a74fde6bd1697888 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Wed, 10 Dec 2025 19:20:42 +0100 Subject: [PATCH 1/3] feat(controller): start, stop, restart, destroy and wipe commands --- README.md | 42 +++-- src/cli.rs | 6 +- src/controller.rs | 307 ++++++++++++++++++++++++++++++++++- src/controller/docker.rs | 81 +++++++++ src/controller/reconciler.rs | 4 +- src/main.rs | 36 ++-- src/state.rs | 6 +- 7 files changed, 440 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index c73f8ad..5600098 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ -``` +```text ▄▄▄▄ ▐▌ █ █ ▐▌ █▄▄▄▀ ▗▞▀▜▌ █ ▗▄▖▝▚▄▟▌ -▀ ▐▌ ▐▌ - ▝▀▜▌ - ▐▙▄▞▘ -``` +▀ ▐▌ ▐▌ + ▝▀▜▌ + ▐▙▄▞▘ +``` + --- > Inspired by the great [Gel](https://github.com/geldata/gel-cli) CLI. We will miss it. @@ -17,7 +18,7 @@ Project-scoped PostgreSQL instance manager for local development. Tired of juggling PostgreSQL versions across projects? Wrestling with port conflicts? Spending half your morning helping new teammates get their local database running? Well, say no more! -`pgd` gives each of your projects its own containerized PostgreSQL instance with zero configuration headaches. +`pgd` gives each of your projects its own containerized PostgreSQL instance with zero configuration headaches. Isolate, upgrade and nuke -- everything safely. ## Why Use pgd? @@ -31,6 +32,7 @@ Isolate, upgrade and nuke -- everything safely. **Let the tool handle the boring stuff.** `pgd` manages ports, volumes and versions for you ## Requirements + - Docker daemon running locally - Rust toolchain (for installation from source) @@ -70,6 +72,15 @@ Creates a `pgd.toml` file in the current directory with auto-populated configura All instance commands follow the pattern `pgd instance `: ```bash +# Start the PostgreSQL instance +pgd instance start + +# Stop the PostgreSQL instance +pgd instance stop + +# Restart the PostgreSQL instance +pgd instance restart + # Check instance status and configuration drift pgd instance status @@ -77,7 +88,7 @@ pgd instance status pgd instance logs # Follow logs in real-time -pgd instance logs --f +pgd instance logs --follow # Get connection details pgd instance conn @@ -92,14 +103,22 @@ pgd instance conn --format human ### Destructive Operations ```bash -# Remove the container +# Stop and remove the container pgd instance destroy # Wipe all database data pgd instance wipe ``` -These commands require confirmation to prevent accidental data loss. +These commands require confirmation to prevent accidental data loss. You can bypass confirmation with the `force` flag, but use with caution: + +```bash +# Force destroy without confirmation +pgd instance destroy --force + +# Force wipe without confirmation +pgd instance wipe --force +``` ### Global Options @@ -119,17 +138,18 @@ pgd --help `pgd` manages Docker containers with PostgreSQL images. Each project's container is named deterministically based on the project directory name, ensuring no duplicates. The tool tracks state separately for each instance to detect configuration drift, such as: + - Version mismatches between `pgd.toml` and the running container - Port conflicts or changes - Container state inconsistencies -When drift is detected, `pgd instance status` will show warnings and correct things. +When drift is detected, `pgd instance status` will show warnings and you can use `pgd instance start` to reconcile the state. ## Project Structure Your project tree after initialization: -``` +```text my-project/ ├── pgd.toml # Database configuration ├── src/ # Your application code diff --git a/src/cli.rs b/src/cli.rs index 41e20f0..d4e1d56 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -22,7 +22,7 @@ pub struct Cli { #[derive(Clone, clap::ValueEnum)] pub enum ConnectionFormat { /// DSN Url - DSN, + Dsn, // Human readable format Human, } @@ -36,9 +36,9 @@ pub enum ControlCommands { /// Restart postgres instance Restart, /// (WARNING!) Destroy postgres instance - Destroy { accept: bool }, + Destroy { force: bool }, /// (WARNING!) Destruct database - Wipe { accept: bool }, + Wipe { force: bool }, /// Status of instance Status, diff --git a/src/controller.rs b/src/controller.rs index ae97580..853cd3e 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -3,7 +3,7 @@ use miette::miette; use colored::Colorize; use comfy_table::{Attribute, Cell, Color, ContentArrangement, Table, presets::UTF8_FULL}; use futures::TryStreamExt; -use miette::Result; +use miette::{IntoDiagnostic, Result}; use crate::{ cli::ConnectionFormat, @@ -90,7 +90,7 @@ impl Controller { reconciler.reconcile(project).await?; match format { - ConnectionFormat::DSN => { + ConnectionFormat::Dsn => { println!( "postgres://{}:{}@127.0.0.1:{}/{}", USERNAME, project.config.password, project.config.port, DATABASE @@ -104,6 +104,297 @@ impl Controller { Ok(()) } + pub async fn start(&self) -> Result<()> { + let instance = self.ctx.require_instance()?; + let project = self.ctx.require_project()?; + + if self + .ctx + .docker + .is_container_running_by_id(&instance.container_id) + .await? + { + println!("{}", "Container is already running".yellow()); + return Ok(()); + } + + println!("{}", "Starting container...".cyan()); + self.ctx + .docker + .start_container_by_id(&instance.container_id) + .await?; + println!( + "{} {} {}", + "✓".green().bold(), + "Container".green(), + project.container_name().yellow() + ); + + Ok(()) + } + + pub async fn stop(&self) -> Result<()> { + let instance = self.ctx.require_instance()?; + let project = self.ctx.require_project()?; + + if !self + .ctx + .docker + .is_container_running_by_id(&instance.container_id) + .await? + { + println!("{}", "Container is not running".yellow()); + return Ok(()); + } + + println!("{}", "Stopping container...".cyan()); + self.ctx + .docker + .stop_container(&instance.container_id, 10) + .await?; + println!( + "{} {} {}", + "✓".green().bold(), + "Stopped container".green(), + project.container_name().yellow() + ); + + Ok(()) + } + + pub async fn restart(&self) -> Result<()> { + let instance = self.ctx.require_instance()?; + let project = self.ctx.require_project()?; + + println!("{}", "Restarting container...".cyan()); + self.ctx + .docker + .restart_container(&instance.container_id, 10) + .await?; + println!( + "{} {} {}", + "✓".green().bold(), + "Restarted container".green(), + project.container_name().yellow() + ); + + Ok(()) + } + + pub async fn destroy(&self, force: bool) -> Result<()> { + let instance = self.ctx.require_instance()?; + let project = self.ctx.require_project()?; + + if !force { + use cliclack::{confirm, outro}; + let confirmed = confirm( + format!( + "Are you sure you want to destroy container '{}'? This will remove the container and all its volumes.", + project.container_name() + ), + ) + .interact() + .into_diagnostic()?; + + if !confirmed { + outro("Operation cancelled".to_string()).into_diagnostic()?; + return Ok(()); + } + } + + println!("{}", "Destroying container...".cyan()); + + // Stop if running + if self + .ctx + .docker + .is_container_running_by_id(&instance.container_id) + .await? + { + self.ctx + .docker + .stop_container(&instance.container_id, 5) + .await?; + } + + // Remove container + self.ctx + .docker + .remove_container(&instance.container_id, true) + .await?; + + // Remove from state + self.ctx.state.remove(&project.name); + self.ctx.state.save()?; + + println!( + "{} {} {}", + "✓".green().bold(), + "Destroyed container".green(), + project.container_name().yellow() + ); + + Ok(()) + } + + pub async fn wipe(&self, force: bool) -> Result<()> { + let instance = self.ctx.require_instance()?; + let project = self.ctx.require_project()?; + + if !force { + use cliclack::{confirm, outro}; + let confirmed = confirm( + "Are you sure you want to wipe all database data? This action cannot be undone." + .to_string(), + ) + .interact() + .into_diagnostic()?; + + if !confirmed { + outro("Operation cancelled".to_string()).into_diagnostic()?; + return Ok(()); + } + } + + let is_running = self + .ctx + .docker + .is_container_running_by_id(&instance.container_id) + .await?; + + if !is_running { + println!("{}", "Starting container to wipe data...".cyan()); + self.ctx + .docker + .start_container_by_id(&instance.container_id) + .await?; + tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; + } + + println!("{}", "Wiping database...".cyan()); + + // Drop and recreate database + let drop_query = format!("DROP DATABASE IF EXISTS {};", DATABASE); + let drop_cmd = vec!["psql", "-U", USERNAME, "-c", &drop_query]; + self.ctx + .docker + .exec_in_container(&instance.container_id, drop_cmd) + .await?; + + let create_query = format!("CREATE DATABASE {};", DATABASE); + let create_cmd = vec!["psql", "-U", USERNAME, "-c", &create_query]; + self.ctx + .docker + .exec_in_container(&instance.container_id, create_cmd) + .await?; + + println!( + "{} {} {}", + "✓".green().bold(), + "Wiped database for".green(), + project.name.yellow() + ); + + Ok(()) + } + + pub async fn status(&self) -> Result<()> { + let project = self.ctx.require_project()?; + + let mut table = create_ui_table(format!("Status: {}", project.name)); + + table.add_row(vec![ + Cell::new("Project").fg(Color::White), + Cell::new(&project.name).add_attribute(Attribute::Bold), + ]); + + table.add_row(vec![ + Cell::new("Container Name").fg(Color::White), + Cell::new(project.container_name()).add_attribute(Attribute::Bold), + ]); + + match &self.ctx.instance { + Some(instance) => { + let exists = self + .ctx + .docker + .container_exists_by_id(&instance.container_id) + .await?; + + if !exists { + table.add_row(vec![ + Cell::new("Status").fg(Color::White), + Cell::new("Container not found").fg(Color::Red), + ]); + } else { + let is_running = self + .ctx + .docker + .is_container_running_by_id(&instance.container_id) + .await?; + + table.add_row(vec![ + Cell::new("Status").fg(Color::White), + if is_running { + Cell::new("Running").fg(Color::Green) + } else { + Cell::new("Stopped").fg(Color::Yellow) + }, + ]); + + table.add_row(vec![ + Cell::new("Container ID").fg(Color::White), + Cell::new(&instance.container_id[..12]).fg(Color::DarkGrey), + ]); + + table.add_row(vec![ + Cell::new("PostgreSQL Version").fg(Color::White), + Cell::new(instance.postgres_version.to_string()) + .add_attribute(Attribute::Bold), + ]); + + table.add_row(vec![ + Cell::new("Port").fg(Color::White), + Cell::new(instance.port.to_string()).add_attribute(Attribute::Bold), + ]); + + // Check for drift + if instance.postgres_version != project.config.version { + table.add_row(vec![ + Cell::new("⚠ Version Drift").fg(Color::Yellow), + Cell::new(format!( + "Config: {}, Container: {}", + project.config.version, instance.postgres_version + )) + .fg(Color::Yellow), + ]); + } + + if instance.port != project.config.port { + table.add_row(vec![ + Cell::new("⚠ Port Drift").fg(Color::Yellow), + Cell::new(format!( + "Config: {}, Container: {}", + project.config.port, instance.port + )) + .fg(Color::Yellow), + ]); + } + } + } + None => { + table.add_row(vec![ + Cell::new("Status").fg(Color::White), + Cell::new("Not initialized").fg(Color::Yellow), + ]); + } + } + + println!("{}", table); + + Ok(()) + } + pub async fn init_project(&self) -> Result<()> { let reconciler = Reconciler { ctx: &self.ctx }; @@ -131,7 +422,7 @@ impl Controller { project.path.display().to_string().bright_white().bold() ); - let mut table = create_ui_table("Project Configuration"); + let mut table = create_ui_table("Project Configuration".to_string()); table.add_row(vec![ Cell::new("Project").fg(Color::White), Cell::new(&project.name).add_attribute(Attribute::Bold), @@ -153,14 +444,18 @@ impl Controller { reconciler.reconcile(&project).await?; - println!("\n{}", "✓ Project initialized successfully!".green().bold()); + println!( + "\n{} {}", + "✓".green().bold(), + "Project initialized successfully!".green(), + ); Ok(()) } } fn format_conn_human(project: &Project) { - let mut table = create_ui_table("Instance"); + let mut table = create_ui_table("Instance".to_string()); table.add_row(vec![ Cell::new("Project").fg(Color::White), Cell::new(&project.name).add_attribute(Attribute::Bold), @@ -190,7 +485,7 @@ fn format_conn_human(project: &Project) { println!("{}", table); } -fn create_ui_table(header: &'static str) -> Table { +fn create_ui_table(header: String) -> Table { let mut table = Table::new(); table .load_preset(UTF8_FULL) diff --git a/src/controller/docker.rs b/src/controller/docker.rs index 63e2c64..d219865 100644 --- a/src/controller/docker.rs +++ b/src/controller/docker.rs @@ -287,4 +287,85 @@ impl DockerController { .logs(container_id, options) .map(|k| k.into_diagnostic().wrap_err("Failed streaming logs")) } + + pub async fn remove_container(&self, container_id: &str, force: bool) -> Result<()> { + use bollard::query_parameters::RemoveContainerOptions; + + self.daemon + .remove_container( + container_id, + Some(RemoveContainerOptions { + force, + v: true, // Remove associated volumes + ..Default::default() + }), + ) + .await + .into_diagnostic() + .wrap_err("Failed to remove container")?; + + Ok(()) + } + + pub async fn restart_container(&self, container_id: &str, timeout: i32) -> Result<()> { + use bollard::query_parameters::RestartContainerOptions; + + self.daemon + .restart_container( + container_id, + Some(RestartContainerOptions { + t: Some(timeout), + signal: None, + }), + ) + .await + .into_diagnostic() + .wrap_err("Failed to restart container")?; + + Ok(()) + } + + pub async fn exec_in_container(&self, container_id: &str, cmd: Vec<&str>) -> Result { + use bollard::container::LogOutput; + use bollard::exec::{CreateExecOptions, StartExecOptions}; + + let exec = self + .daemon + .create_exec( + container_id, + CreateExecOptions { + cmd: Some(cmd), + attach_stdout: Some(true), + attach_stderr: Some(true), + ..Default::default() + }, + ) + .await + .into_diagnostic() + .wrap_err("Failed to create exec")?; + + let mut output = String::new(); + let start_exec_result = self + .daemon + .start_exec(&exec.id, Some(StartExecOptions::default())) + .await + .into_diagnostic()?; + + if let bollard::exec::StartExecResults::Attached { + output: mut exec_output, + .. + } = start_exec_result + { + while let Some(Ok(msg)) = exec_output.next().await { + match msg { + LogOutput::StdOut { message } | LogOutput::StdErr { message } => { + output.push_str(&String::from_utf8_lossy(&message)); + } + _ => {} + } + } + } + + Ok(output) + } } diff --git a/src/controller/reconciler.rs b/src/controller/reconciler.rs index 02ac686..62fe981 100644 --- a/src/controller/reconciler.rs +++ b/src/controller/reconciler.rs @@ -120,7 +120,7 @@ impl<'a> Reconciler<'a> { async fn try_starting_container( &self, - container_id: &String, + container_id: &str, spinner: &indicatif::ProgressBar, ) -> Result<(), miette::Error> { match self.ctx.docker.start_container_by_id(container_id).await { @@ -175,7 +175,7 @@ impl<'a> Reconciler<'a> { ) .await?; info!("{}", "Container created successfully".green()); - self.ctx.state.set( + self.ctx.state.upsert( project.name.clone(), crate::state::InstanceState::new( id.clone(), diff --git a/src/main.rs b/src/main.rs index d90763b..18e3f30 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,13 +6,11 @@ mod consts; mod controller; -use std::env::args; - use clap::Parser; use clap_verbosity_flag::Verbosity; use cli::Cli; use miette::Result; -use tracing::{debug, info}; +use tracing::debug; use crate::{ cli::ControlCommands, @@ -26,27 +24,27 @@ async fn main() -> Result<()> { debug!("pgd.start"); + macro_rules! do_cmd { + ($name:expr, $method:ident $(, $arg:expr)*) => {{ + let ctx = Context::new($name).await?; + Controller::new(ctx).$method($($arg),*).await?; + }}; + } + match cli.command { cli::Commands::Init => { - let ctx = Context::new(None).await?; - Controller::new(ctx).init_project().await?; + do_cmd!(None, init_project); } cli::Commands::Instance { name, cmd } => match cmd { - ControlCommands::Start => {} - ControlCommands::Stop => {} - ControlCommands::Restart => {} - ControlCommands::Destroy { accept } => {} - ControlCommands::Logs { follow } => { - let ctx = Context::new(name).await?; - Controller::new(ctx).logs(follow).await?; - } - ControlCommands::Status => {} + ControlCommands::Start => do_cmd!(name, start), + ControlCommands::Stop => do_cmd!(name, stop), + ControlCommands::Restart => do_cmd!(name, restart), + ControlCommands::Destroy { force } => do_cmd!(name, destroy, force), + ControlCommands::Logs { follow } => do_cmd!(name, logs, follow), + ControlCommands::Status => do_cmd!(name, status), // can't override an instance for this command, because password is in config - ControlCommands::Conn { format } => { - let ctx = Context::new(None).await?; - Controller::new(ctx).show_connection(format).await?; - } - ControlCommands::Wipe { accept } => {} + ControlCommands::Conn { format } => do_cmd!(None, show_connection, format), + ControlCommands::Wipe { force } => do_cmd!(name, wipe, force), }, } diff --git a/src/state.rs b/src/state.rs index 07bd868..9a4959d 100644 --- a/src/state.rs +++ b/src/state.rs @@ -84,10 +84,14 @@ impl StateManager { self.0.borrow().instances.get(project_name).cloned() } - pub fn set(&self, project_name: String, state: InstanceState) { + pub fn upsert(&self, project_name: String, state: InstanceState) { self.0.borrow_mut().instances.insert(project_name, state); } + pub fn remove(&self, project_name: &str) -> Option { + self.0.borrow_mut().instances.remove(project_name) + } + pub fn get_highest_used_port(&self) -> Option { self.0.borrow().instances.values().map(|i| i.port).max() } From 4c64e411f9dcf062dc134090820595ceaa1f23c6 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Wed, 10 Dec 2025 20:04:50 +0100 Subject: [PATCH 2/3] refactor(controller): simplify start and restart methods by using reconciler --- src/controller.rs | 26 +++++--------------------- 1 file changed, 5 insertions(+), 21 deletions(-) diff --git a/src/controller.rs b/src/controller.rs index 853cd3e..43ae2c4 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -105,28 +105,15 @@ impl Controller { } pub async fn start(&self) -> Result<()> { - let instance = self.ctx.require_instance()?; let project = self.ctx.require_project()?; - - if self - .ctx - .docker - .is_container_running_by_id(&instance.container_id) - .await? - { - println!("{}", "Container is already running".yellow()); - return Ok(()); - } + let reconciler = Reconciler { ctx: &self.ctx }; println!("{}", "Starting container...".cyan()); - self.ctx - .docker - .start_container_by_id(&instance.container_id) - .await?; + reconciler.reconcile(project).await?; println!( "{} {} {}", "✓".green().bold(), - "Container".green(), + "Container started".green(), project.container_name().yellow() ); @@ -163,14 +150,11 @@ impl Controller { } pub async fn restart(&self) -> Result<()> { - let instance = self.ctx.require_instance()?; let project = self.ctx.require_project()?; + let reconciler = Reconciler { ctx: &self.ctx }; println!("{}", "Restarting container...".cyan()); - self.ctx - .docker - .restart_container(&instance.container_id, 10) - .await?; + reconciler.reconcile(project).await?; println!( "{} {} {}", "✓".green().bold(), From 5d215fff8e6545916b44114a199feb100f51ca87 Mon Sep 17 00:00:00 2001 From: CleverWild Date: Thu, 11 Dec 2025 17:28:32 +0100 Subject: [PATCH 3/3] fix(controller): absence of stopping before restarting --- src/controller.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/controller.rs b/src/controller.rs index 43ae2c4..5442d23 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -150,10 +150,25 @@ impl Controller { } pub async fn restart(&self) -> Result<()> { + let instance = self.ctx.require_instance()?; let project = self.ctx.require_project()?; let reconciler = Reconciler { ctx: &self.ctx }; println!("{}", "Restarting container...".cyan()); + + // Stop container first if it's running, otherwise reconciler won't do anything + if self + .ctx + .docker + .is_container_running_by_id(&instance.container_id) + .await? + { + self.ctx + .docker + .stop_container(&instance.container_id, 10) + .await?; + } + reconciler.reconcile(project).await?; println!( "{} {} {}",