Compare commits
11 Commits
5b8feaa3c8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f89832d736 | |||
| 5d215fff8e | |||
| 4c64e411f9 | |||
| 6d16748cb3 | |||
|
|
a01f511311 | ||
|
|
be866cfb1a | ||
|
|
ef7dc8ba07 | ||
|
|
471f9129b4 | ||
|
|
570cec8b47 | ||
|
|
04797a5fb9 | ||
|
|
eee5f4c714 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
/target
|
/target
|
||||||
|
pgd.toml
|
||||||
|
|||||||
24
Cargo.lock
generated
24
Cargo.lock
generated
@@ -219,6 +219,17 @@ dependencies = [
|
|||||||
"clap_derive",
|
"clap_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap-verbosity-flag"
|
||||||
|
version = "3.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9d92b1fab272fe943881b77cc6e920d6543e5b1bfadbd5ed81c7c5a755742394"
|
||||||
|
dependencies = [
|
||||||
|
"clap",
|
||||||
|
"log",
|
||||||
|
"tracing-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_builder"
|
name = "clap_builder"
|
||||||
version = "4.5.53"
|
version = "4.5.53"
|
||||||
@@ -409,15 +420,6 @@ dependencies = [
|
|||||||
"litrs",
|
"litrs",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "dsn"
|
|
||||||
version = "1.2.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a68ec86c8ab056c40c4d3f6a543ad0ad6a251d7d01dac251feed242aa44e754a"
|
|
||||||
dependencies = [
|
|
||||||
"percent-encoding",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dyn-clone"
|
name = "dyn-clone"
|
||||||
version = "1.0.20"
|
version = "1.0.20"
|
||||||
@@ -1096,14 +1098,14 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pgd"
|
name = "pgd"
|
||||||
version = "0.0.1"
|
version = "0.0.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bollard",
|
"bollard",
|
||||||
"clap",
|
"clap",
|
||||||
|
"clap-verbosity-flag",
|
||||||
"cliclack",
|
"cliclack",
|
||||||
"colored",
|
"colored",
|
||||||
"comfy-table",
|
"comfy-table",
|
||||||
"dsn",
|
|
||||||
"futures",
|
"futures",
|
||||||
"indicatif",
|
"indicatif",
|
||||||
"miette",
|
"miette",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "pgd"
|
name = "pgd"
|
||||||
version = "0.0.1"
|
version = "0.0.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "CLI to manage postgres instances for local development"
|
description = "CLI to manage postgres instances for local development"
|
||||||
repository = "https://github.com/MarketTakers/pgd"
|
repository = "https://github.com/MarketTakers/pgd"
|
||||||
@@ -9,10 +9,10 @@ license = "MIT"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
bollard = "0.19.4"
|
bollard = "0.19.4"
|
||||||
clap = { version = "4.5.53", features = ["derive"] }
|
clap = { version = "4.5.53", features = ["derive"] }
|
||||||
|
clap-verbosity-flag = { version = "3.0.4", features = ["tracing"] }
|
||||||
cliclack = "0.3.7"
|
cliclack = "0.3.7"
|
||||||
colored = "3.0.0"
|
colored = "3.0.0"
|
||||||
comfy-table = "7.2.1"
|
comfy-table = "7.2.1"
|
||||||
dsn = "1.2.1"
|
|
||||||
futures = "0.3.31"
|
futures = "0.3.31"
|
||||||
indicatif = { version = "0.18.3", features = ["improved_unicode"] }
|
indicatif = { version = "0.18.3", features = ["improved_unicode"] }
|
||||||
miette = { version = "7.6.0", features = ["fancy"] }
|
miette = { version = "7.6.0", features = ["fancy"] }
|
||||||
|
|||||||
42
README.md
42
README.md
@@ -1,12 +1,13 @@
|
|||||||
```
|
```text
|
||||||
▄▄▄▄ ▐▌
|
▄▄▄▄ ▐▌
|
||||||
█ █ ▐▌
|
█ █ ▐▌
|
||||||
█▄▄▄▀ ▗▞▀▜▌
|
█▄▄▄▀ ▗▞▀▜▌
|
||||||
█ ▗▄▖▝▚▄▟▌
|
█ ▗▄▖▝▚▄▟▌
|
||||||
▀ ▐▌ ▐▌
|
▀ ▐▌ ▐▌
|
||||||
▝▀▜▌
|
▝▀▜▌
|
||||||
▐▙▄▞▘
|
▐▙▄▞▘
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
> Inspired by the great [Gel](https://github.com/geldata/gel-cli) CLI. We will miss it.
|
> 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!
|
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.
|
Isolate, upgrade and nuke -- everything safely.
|
||||||
|
|
||||||
## Why Use pgd?
|
## 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
|
**Let the tool handle the boring stuff.** `pgd` manages ports, volumes and versions for you
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Docker daemon running locally
|
- Docker daemon running locally
|
||||||
- Rust toolchain (for installation from source)
|
- 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>`:
|
All instance commands follow the pattern `pgd instance <command>`:
|
||||||
|
|
||||||
```bash
|
```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
|
# Check instance status and configuration drift
|
||||||
pgd instance status
|
pgd instance status
|
||||||
|
|
||||||
@@ -77,7 +88,7 @@ pgd instance status
|
|||||||
pgd instance logs
|
pgd instance logs
|
||||||
|
|
||||||
# Follow logs in real-time
|
# Follow logs in real-time
|
||||||
pgd instance logs --f
|
pgd instance logs --follow
|
||||||
|
|
||||||
# Get connection details
|
# Get connection details
|
||||||
pgd instance conn
|
pgd instance conn
|
||||||
@@ -92,14 +103,22 @@ pgd instance conn --format human
|
|||||||
### Destructive Operations
|
### Destructive Operations
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Remove the container
|
# Stop and remove the container
|
||||||
pgd instance destroy
|
pgd instance destroy
|
||||||
|
|
||||||
# Wipe all database data
|
# Wipe all database data
|
||||||
pgd instance wipe
|
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
|
### 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.
|
`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:
|
The tool tracks state separately for each instance to detect configuration drift, such as:
|
||||||
|
|
||||||
- Version mismatches between `pgd.toml` and the running container
|
- Version mismatches between `pgd.toml` and the running container
|
||||||
- Port conflicts or changes
|
- Port conflicts or changes
|
||||||
- Container state inconsistencies
|
- 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
|
## Project Structure
|
||||||
|
|
||||||
Your project tree after initialization:
|
Your project tree after initialization:
|
||||||
|
|
||||||
```
|
```text
|
||||||
my-project/
|
my-project/
|
||||||
├── pgd.toml # Database configuration
|
├── pgd.toml # Database configuration
|
||||||
├── src/ # Your application code
|
├── src/ # Your application code
|
||||||
|
|||||||
@@ -4,4 +4,6 @@
|
|||||||
█ ▗▄▖▝▚▄▟▌
|
█ ▗▄▖▝▚▄▟▌
|
||||||
▀ ▐▌ ▐▌
|
▀ ▐▌ ▐▌
|
||||||
▝▀▜▌
|
▝▀▜▌
|
||||||
▐▙▄▞▘
|
▐▙▄▞▘
|
||||||
|
|
||||||
|
Project-scoped PostgreSQL instance manager
|
||||||
|
|||||||
12
src/cli.rs
12
src/cli.rs
@@ -8,21 +8,21 @@ const STYLES: styling::Styles = styling::Styles::styled()
|
|||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "pgd")]
|
#[command(name = "pgd")]
|
||||||
#[command(about = "Project-scoped PostgreSQL instance manager", long_about = None)]
|
#[command(about = "Project-scoped PostgreSQL instance manager", long_about = include_str!("./banner.txt"))]
|
||||||
#[command(version)]
|
#[command(version)]
|
||||||
#[command(styles = STYLES)]
|
#[command(styles = STYLES)]
|
||||||
pub struct Cli {
|
pub struct Cli {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
pub command: Commands,
|
pub command: Commands,
|
||||||
|
|
||||||
#[arg(short, long, global = true)]
|
#[command(flatten)]
|
||||||
pub verbose: bool,
|
pub verbosity: clap_verbosity_flag::Verbosity,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, clap::ValueEnum)]
|
#[derive(Clone, clap::ValueEnum)]
|
||||||
pub enum ConnectionFormat {
|
pub enum ConnectionFormat {
|
||||||
/// DSN Url
|
/// DSN Url
|
||||||
DSN,
|
Dsn,
|
||||||
// Human readable format
|
// Human readable format
|
||||||
Human,
|
Human,
|
||||||
}
|
}
|
||||||
@@ -36,9 +36,9 @@ pub enum ControlCommands {
|
|||||||
/// Restart postgres instance
|
/// Restart postgres instance
|
||||||
Restart,
|
Restart,
|
||||||
/// (WARNING!) Destroy postgres instance
|
/// (WARNING!) Destroy postgres instance
|
||||||
Destroy { accept: bool },
|
Destroy { force: bool },
|
||||||
/// (WARNING!) Destruct database
|
/// (WARNING!) Destruct database
|
||||||
Wipe { accept: bool },
|
Wipe { force: bool },
|
||||||
|
|
||||||
/// Status of instance
|
/// Status of instance
|
||||||
Status,
|
Status,
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
use dsn::DSN;
|
|
||||||
use miette::miette;
|
use miette::miette;
|
||||||
|
|
||||||
use colored::Colorize;
|
use colored::Colorize;
|
||||||
use comfy_table::{Attribute, Cell, Color, ContentArrangement, Table, presets::UTF8_FULL};
|
use comfy_table::{Attribute, Cell, Color, ContentArrangement, Table, presets::UTF8_FULL};
|
||||||
use futures::TryStreamExt;
|
use futures::TryStreamExt;
|
||||||
use miette::Result;
|
use miette::{IntoDiagnostic, Result};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
cli::ConnectionFormat,
|
cli::ConnectionFormat,
|
||||||
@@ -91,16 +90,11 @@ impl Controller {
|
|||||||
reconciler.reconcile(project).await?;
|
reconciler.reconcile(project).await?;
|
||||||
|
|
||||||
match format {
|
match format {
|
||||||
ConnectionFormat::DSN => {
|
ConnectionFormat::Dsn => {
|
||||||
let dsn = DSN::builder()
|
println!(
|
||||||
.driver("postgres")
|
"postgres://{}:{}@127.0.0.1:{}/{}",
|
||||||
.username(USERNAME)
|
USERNAME, project.config.password, project.config.port, DATABASE
|
||||||
.password(project.config.password.clone())
|
);
|
||||||
.host("127.0.0.1")
|
|
||||||
.port(project.config.port)
|
|
||||||
.database(DATABASE)
|
|
||||||
.build();
|
|
||||||
println!("{}", dsn.to_string());
|
|
||||||
}
|
}
|
||||||
ConnectionFormat::Human => {
|
ConnectionFormat::Human => {
|
||||||
format_conn_human(project);
|
format_conn_human(project);
|
||||||
@@ -110,6 +104,296 @@ impl Controller {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn start(&self) -> Result<()> {
|
||||||
|
let project = self.ctx.require_project()?;
|
||||||
|
let reconciler = Reconciler { ctx: &self.ctx };
|
||||||
|
|
||||||
|
println!("{}", "Starting container...".cyan());
|
||||||
|
reconciler.reconcile(project).await?;
|
||||||
|
println!(
|
||||||
|
"{} {} {}",
|
||||||
|
"✓".green().bold(),
|
||||||
|
"Container started".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()?;
|
||||||
|
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!(
|
||||||
|
"{} {} {}",
|
||||||
|
"✓".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<()> {
|
pub async fn init_project(&self) -> Result<()> {
|
||||||
let reconciler = Reconciler { ctx: &self.ctx };
|
let reconciler = Reconciler { ctx: &self.ctx };
|
||||||
|
|
||||||
@@ -128,17 +412,16 @@ impl Controller {
|
|||||||
let config = PGDConfig {
|
let config = PGDConfig {
|
||||||
version: *latest_version,
|
version: *latest_version,
|
||||||
password: utils::generate_password(),
|
password: utils::generate_password(),
|
||||||
port: utils::find_available_port()?,
|
port: utils::find_available_port(&self.ctx.state)?,
|
||||||
};
|
};
|
||||||
let project = Project::new(config)?;
|
let project = Project::new(config)?;
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"\n{} {}\n",
|
"\nCreated pgd.toml in {}\n",
|
||||||
"Created pgd.toml in",
|
|
||||||
project.path.display().to_string().bright_white().bold()
|
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![
|
table.add_row(vec![
|
||||||
Cell::new("Project").fg(Color::White),
|
Cell::new("Project").fg(Color::White),
|
||||||
Cell::new(&project.name).add_attribute(Attribute::Bold),
|
Cell::new(&project.name).add_attribute(Attribute::Bold),
|
||||||
@@ -160,14 +443,18 @@ impl Controller {
|
|||||||
|
|
||||||
reconciler.reconcile(&project).await?;
|
reconciler.reconcile(&project).await?;
|
||||||
|
|
||||||
println!("\n{}", "✓ Project initialized successfully!".green().bold());
|
println!(
|
||||||
|
"\n{} {}",
|
||||||
|
"✓".green().bold(),
|
||||||
|
"Project initialized successfully!".green(),
|
||||||
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_conn_human(project: &Project) {
|
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![
|
table.add_row(vec![
|
||||||
Cell::new("Project").fg(Color::White),
|
Cell::new("Project").fg(Color::White),
|
||||||
Cell::new(&project.name).add_attribute(Attribute::Bold),
|
Cell::new(&project.name).add_attribute(Attribute::Bold),
|
||||||
@@ -197,7 +484,7 @@ fn format_conn_human(project: &Project) {
|
|||||||
println!("{}", table);
|
println!("{}", table);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_ui_table(header: &'static str) -> Table {
|
fn create_ui_table(header: String) -> Table {
|
||||||
let mut table = Table::new();
|
let mut table = Table::new();
|
||||||
table
|
table
|
||||||
.load_preset(UTF8_FULL)
|
.load_preset(UTF8_FULL)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use miette::{Diagnostic, miette};
|
use miette::{Diagnostic, miette};
|
||||||
use std::{io::Write, str::FromStr};
|
use std::str::FromStr;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use bollard::{
|
use bollard::{
|
||||||
@@ -15,7 +15,7 @@ use colored::Colorize;
|
|||||||
use futures::{Stream, StreamExt};
|
use futures::{Stream, StreamExt};
|
||||||
use indicatif::MultiProgress;
|
use indicatif::MultiProgress;
|
||||||
use miette::{Context, IntoDiagnostic, Result};
|
use miette::{Context, IntoDiagnostic, Result};
|
||||||
use tracing::info;
|
use tracing::{debug, info};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::PostgresVersion,
|
config::PostgresVersion,
|
||||||
@@ -46,7 +46,7 @@ impl DockerController {
|
|||||||
"Failed to connect to Docker! pgd required Docker installed. Make sure it's running.",
|
"Failed to connect to Docker! pgd required Docker installed. Make sure it's running.",
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
info!("docker.created");
|
debug!("Connected to docker!");
|
||||||
|
|
||||||
docker
|
docker
|
||||||
.list_images(Some(ListImagesOptions::default()))
|
.list_images(Some(ListImagesOptions::default()))
|
||||||
@@ -283,11 +283,89 @@ impl DockerController {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
|
|
||||||
let logs = self
|
self.daemon
|
||||||
.daemon
|
|
||||||
.logs(container_id, options)
|
.logs(container_id, options)
|
||||||
.map(|k| k.into_diagnostic().wrap_err("Failed streaming logs"));
|
.map(|k| k.into_diagnostic().wrap_err("Failed streaming logs"))
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use indicatif::{MultiProgress, ProgressBar, ProgressState, ProgressStyle};
|
|||||||
fn new_download_pb(multi: &MultiProgress, layer_id: &str) -> ProgressBar {
|
fn new_download_pb(multi: &MultiProgress, layer_id: &str) -> ProgressBar {
|
||||||
let pb = multi.add(ProgressBar::new(0));
|
let pb = multi.add(ProgressBar::new(0));
|
||||||
pb.set_style(
|
pb.set_style(
|
||||||
ProgressStyle::with_template(&"{spinner:.green} [{elapsed_precise}] {msg} [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({eta})".to_string())
|
ProgressStyle::with_template("{spinner:.green} [{elapsed_precise}] {msg} [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({eta})")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.with_key("eta", |state: &ProgressState, w: &mut dyn Write| {
|
.with_key("eta", |state: &ProgressState, w: &mut dyn Write| {
|
||||||
write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap()
|
write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use miette::{Diagnostic, bail};
|
|||||||
use colored::Colorize;
|
use colored::Colorize;
|
||||||
use miette::Result;
|
use miette::Result;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::{PostgresVersion, Project},
|
config::{PostgresVersion, Project},
|
||||||
@@ -66,7 +67,7 @@ impl<'a> Reconciler<'a> {
|
|||||||
.is_container_running_by_id(&container_id)
|
.is_container_running_by_id(&container_id)
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
println!("{}", "Container is already running".white());
|
info!("Container is already running");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +120,7 @@ impl<'a> Reconciler<'a> {
|
|||||||
|
|
||||||
async fn try_starting_container(
|
async fn try_starting_container(
|
||||||
&self,
|
&self,
|
||||||
container_id: &String,
|
container_id: &str,
|
||||||
spinner: &indicatif::ProgressBar,
|
spinner: &indicatif::ProgressBar,
|
||||||
) -> Result<(), miette::Error> {
|
) -> Result<(), miette::Error> {
|
||||||
match self.ctx.docker.start_container_by_id(container_id).await {
|
match self.ctx.docker.start_container_by_id(container_id).await {
|
||||||
@@ -146,7 +147,7 @@ impl<'a> Reconciler<'a> {
|
|||||||
.is_container_running_by_id(container_id)
|
.is_container_running_by_id(container_id)
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
return Ok(());
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
miette::bail!("Container stopped unexpectedly after start");
|
miette::bail!("Container stopped unexpectedly after start");
|
||||||
}
|
}
|
||||||
@@ -158,7 +159,7 @@ impl<'a> Reconciler<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn update_project_container(&self, project: &Project) -> Result<String, miette::Error> {
|
async fn update_project_container(&self, project: &Project) -> Result<String, miette::Error> {
|
||||||
println!(
|
info!(
|
||||||
"{} {}",
|
"{} {}",
|
||||||
"Creating container".cyan(),
|
"Creating container".cyan(),
|
||||||
project.container_name().yellow()
|
project.container_name().yellow()
|
||||||
@@ -173,8 +174,8 @@ impl<'a> Reconciler<'a> {
|
|||||||
project.config.port,
|
project.config.port,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
println!("{}", "Container created successfully".green());
|
info!("{}", "Container created successfully".green());
|
||||||
self.ctx.state.set(
|
self.ctx.state.upsert(
|
||||||
project.name.clone(),
|
project.name.clone(),
|
||||||
crate::state::InstanceState::new(
|
crate::state::InstanceState::new(
|
||||||
id.clone(),
|
id.clone(),
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
use miette::Result;
|
use miette::Result;
|
||||||
use rand::{Rng, distr::Alphanumeric};
|
use rand::{Rng, distr::Alphanumeric};
|
||||||
|
|
||||||
|
use crate::state::StateManager;
|
||||||
const DEFAULT_POSTGRES_PORT: u16 = 5432;
|
const DEFAULT_POSTGRES_PORT: u16 = 5432;
|
||||||
const PORT_SEARCH_RANGE: u16 = 100;
|
const PORT_SEARCH_RANGE: u16 = 100;
|
||||||
|
|
||||||
pub fn find_available_port() -> Result<u16> {
|
pub fn find_available_port(state: &StateManager) -> Result<u16> {
|
||||||
use std::net::TcpListener;
|
use std::net::TcpListener;
|
||||||
|
|
||||||
for port in DEFAULT_POSTGRES_PORT..(DEFAULT_POSTGRES_PORT + PORT_SEARCH_RANGE) {
|
let starting_port = state
|
||||||
|
.get_highest_used_port()
|
||||||
|
.unwrap_or(DEFAULT_POSTGRES_PORT);
|
||||||
|
|
||||||
|
for port in starting_port..(starting_port + PORT_SEARCH_RANGE) {
|
||||||
if TcpListener::bind(("127.0.0.1", port)).is_ok() {
|
if TcpListener::bind(("127.0.0.1", port)).is_ok() {
|
||||||
return Ok(port);
|
return Ok(port);
|
||||||
}
|
}
|
||||||
|
|||||||
49
src/main.rs
49
src/main.rs
@@ -7,9 +7,10 @@ mod consts;
|
|||||||
mod controller;
|
mod controller;
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
use clap_verbosity_flag::Verbosity;
|
||||||
use cli::Cli;
|
use cli::Cli;
|
||||||
use miette::Result;
|
use miette::Result;
|
||||||
use tracing::info;
|
use tracing::debug;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
cli::ControlCommands,
|
cli::ControlCommands,
|
||||||
@@ -18,40 +19,42 @@ use crate::{
|
|||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
println!("{}", include_str!("./banner.txt"));
|
|
||||||
|
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
init_tracing(cli.verbose);
|
init_tracing(cli.verbosity);
|
||||||
|
|
||||||
info!("pgd.start");
|
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 {
|
match cli.command {
|
||||||
cli::Commands::Init => {
|
cli::Commands::Init => {
|
||||||
let ctx = Context::new(None).await?;
|
do_cmd!(None, init_project);
|
||||||
Controller::new(ctx).init_project().await?;
|
|
||||||
}
|
}
|
||||||
cli::Commands::Instance { name, cmd } => match cmd {
|
cli::Commands::Instance { name, cmd } => match cmd {
|
||||||
ControlCommands::Start => {}
|
ControlCommands::Start => do_cmd!(name, start),
|
||||||
ControlCommands::Stop => {}
|
ControlCommands::Stop => do_cmd!(name, stop),
|
||||||
ControlCommands::Restart => {}
|
ControlCommands::Restart => do_cmd!(name, restart),
|
||||||
ControlCommands::Destroy { accept } => {}
|
ControlCommands::Destroy { force } => do_cmd!(name, destroy, force),
|
||||||
ControlCommands::Logs { follow } => {
|
ControlCommands::Logs { follow } => do_cmd!(name, logs, follow),
|
||||||
let ctx = Context::new(name).await?;
|
ControlCommands::Status => do_cmd!(name, status),
|
||||||
Controller::new(ctx).logs(follow).await?;
|
|
||||||
}
|
|
||||||
ControlCommands::Status => {}
|
|
||||||
// can't override an instance for this command, because password is in config
|
// can't override an instance for this command, because password is in config
|
||||||
ControlCommands::Conn { format } => {
|
ControlCommands::Conn { format } => do_cmd!(None, show_connection, format),
|
||||||
let ctx = Context::new(None).await?;
|
ControlCommands::Wipe { force } => do_cmd!(name, wipe, force),
|
||||||
Controller::new(ctx).show_connection(format).await?;
|
|
||||||
}
|
|
||||||
ControlCommands::Wipe { accept } => {}
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn init_tracing(_verbose: bool) {
|
fn init_tracing(verbosity: Verbosity) {
|
||||||
tracing_subscriber::fmt::init();
|
tracing_subscriber::fmt()
|
||||||
|
.with_max_level(verbosity)
|
||||||
|
.without_time()
|
||||||
|
.with_target(false)
|
||||||
|
.init();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,13 +84,17 @@ impl StateManager {
|
|||||||
self.0.borrow().instances.get(project_name).cloned()
|
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);
|
self.0.borrow_mut().instances.insert(project_name, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn remove(&self, project_name: &str) -> Option<InstanceState> {
|
pub fn remove(&self, project_name: &str) -> Option<InstanceState> {
|
||||||
self.0.borrow_mut().instances.remove(project_name)
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InstanceState {
|
impl InstanceState {
|
||||||
|
|||||||
Reference in New Issue
Block a user