Compare commits

...

9 Commits

12 changed files with 491 additions and 88 deletions

22
Cargo.lock generated
View File

@@ -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"
@@ -1100,10 +1102,10 @@ 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",

View File

@@ -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"] }

View File

@@ -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. > 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 **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

View File

@@ -5,3 +5,5 @@
▀ ▐▌ ▐▌ ▀ ▐▌ ▐▌
▝▀▜▌ ▝▀▜▌
▐▙▄▞▘ ▐▙▄▞▘
Project-scoped PostgreSQL instance manager

View File

@@ -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,

View File

@@ -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)

View File

@@ -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)
} }
} }

View File

@@ -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()

View File

@@ -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(),

View File

@@ -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);
} }

View File

@@ -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();
} }

View File

@@ -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 {