feat(controller): start, stop, restart, destroy and wipe commands
This commit is contained in:
32
README.md
32
README.md
@@ -1,4 +1,4 @@
|
||||
```
|
||||
```text
|
||||
▄▄▄▄ ▐▌
|
||||
█ █ ▐▌
|
||||
█▄▄▄▀ ▗▞▀▜▌
|
||||
@@ -7,6 +7,7 @@
|
||||
▝▀▜▌
|
||||
▐▙▄▞▘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> Inspired by the great [Gel](https://github.com/geldata/gel-cli) CLI. We will miss it.
|
||||
@@ -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 <command>`:
|
||||
|
||||
```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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<String> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
36
src/main.rs
36
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),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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<InstanceState> {
|
||||
self.0.borrow_mut().instances.remove(project_name)
|
||||
}
|
||||
|
||||
pub fn get_highest_used_port(&self) -> Option<u16> {
|
||||
self.0.borrow().instances.values().map(|i| i.port).max()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user