Compare commits

..

16 Commits

Author SHA1 Message Date
f89832d736 Merge pull request 'feat(controller): start, stop, restart, destroy and wipe commands' (#1) from impl-commands into main
Reviewed-on: #1
Reviewed-by: Stas <business@jexter.tech>
2025-12-12 12:34:29 +00:00
5d215fff8e fix(controller): absence of stopping before restarting 2025-12-11 17:28:32 +01:00
4c64e411f9 refactor(controller): simplify start and restart methods by using reconciler 2025-12-10 20:04:50 +01:00
6d16748cb3 feat(controller): start, stop, restart, destroy and wipe commands 2025-12-10 19:20:42 +01:00
hdbg
a01f511311 feat(cli): add clap-verbosity-flag for enhanced logging control 2025-12-08 15:16:23 +01:00
hdbg
be866cfb1a refactor(cli): embed banner.txt content as CLI long_about description 2025-12-08 14:38:11 +01:00
hdbg
ef7dc8ba07 houskeeping: linter run 2025-12-08 14:32:38 +01:00
hdbg
471f9129b4 refactor(controller): use state to find available port range 2025-12-08 14:32:13 +01:00
hdbg
570cec8b47 refactor(controller): simplify PostgreSQL DSN connection string output 2025-12-08 14:28:16 +01:00
hdbg
04797a5fb9 chore(config): remove pgd.toml and update .gitignore 2025-12-06 20:52:57 +01:00
hdbg
eee5f4c714 chore: Release pgd version 0.0.2 2025-12-06 20:50:28 +01:00
hdbg
5b8feaa3c8 chore(release): add initial release.toml configuration 2025-12-06 20:49:35 +01:00
hdbg
9de82fb71a refactor(cli): rename Connection command to Conn for brevity 2025-12-06 20:47:20 +01:00
hdbg
c6929255e3 feat(logs): implement streaming logs with follow option 2025-12-06 20:32:16 +01:00
hdbg
676c53fabb feat(cli): add connection command with DSN and human formats 2025-12-06 20:20:59 +01:00
hdbg
c45e9305e5 refactor: splitted controller and reconciler 2025-12-06 19:55:44 +01:00
16 changed files with 1158 additions and 327 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
/target /target
pgd.toml

73
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"
@@ -249,6 +260,20 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
[[package]]
name = "cliclack"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2381872509dfa50d8b92b92a5da8367ba68458ab9494be4134b57ad6ca26295f"
dependencies = [
"console 0.15.11",
"indicatif",
"once_cell",
"strsim",
"textwrap",
"zeroize",
]
[[package]] [[package]]
name = "colorchoice" name = "colorchoice"
version = "1.0.4" version = "1.0.4"
@@ -275,6 +300,19 @@ dependencies = [
"unicode-width 0.2.2", "unicode-width 0.2.2",
] ]
[[package]]
name = "console"
version = "0.15.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
dependencies = [
"encode_unicode",
"libc",
"once_cell",
"unicode-width 0.2.2",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "console" name = "console"
version = "0.16.1" version = "0.16.1"
@@ -841,7 +879,7 @@ version = "0.18.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9375e112e4b463ec1b1c6c011953545c65a30164fbab5b581df32b3abf0dcb88" checksum = "9375e112e4b463ec1b1c6c011953545c65a30164fbab5b581df32b3abf0dcb88"
dependencies = [ dependencies = [
"console", "console 0.16.1",
"portable-atomic", "portable-atomic",
"unicode-segmentation", "unicode-segmentation",
"unicode-width 0.2.2", "unicode-width 0.2.2",
@@ -1060,19 +1098,23 @@ 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",
"colored", "colored",
"comfy-table", "comfy-table",
"futures", "futures",
"indicatif", "indicatif",
"miette", "miette",
"parking_lot",
"rand", "rand",
"serde", "serde",
"serde_json", "serde_json",
"serde_with", "serde_with",
"thiserror",
"tokio", "tokio",
"toml", "toml",
"tracing", "tracing",
@@ -1406,6 +1448,12 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "smawk"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c"
[[package]] [[package]]
name = "socket2" name = "socket2"
version = "0.6.1" version = "0.6.1"
@@ -1487,6 +1535,7 @@ version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
dependencies = [ dependencies = [
"smawk",
"unicode-linebreak", "unicode-linebreak",
"unicode-width 0.2.2", "unicode-width 0.2.2",
] ]
@@ -2174,6 +2223,26 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"
version = "0.2.3" version = "0.2.3"

View File

@@ -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,15 +9,19 @@ 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"
colored = "3.0.0" colored = "3.0.0"
comfy-table = "7.2.1" comfy-table = "7.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"] }
parking_lot = "0.12.5"
rand = "0.9.2" rand = "0.9.2"
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145" serde_json = "1.0.145"
serde_with = "3.16.1" serde_with = "3.16.1"
thiserror = "2.0.17"
tokio = { version = "1.48.0", features = ["full"] } tokio = { version = "1.48.0", features = ["full"] }
toml = "0.9.8" toml = "0.9.8"
tracing = "0.1.43" tracing = "0.1.43"

159
README.md Normal file
View File

@@ -0,0 +1,159 @@
```text
▄▄▄▄ ▐▌
█ █ ▐▌
█▄▄▄▀ ▗▞▀▜▌
█ ▗▄▖▝▚▄▟▌
▀ ▐▌ ▐▌
▝▀▜▌
▐▙▄▞▘
```
---
> Inspired by the great [Gel](https://github.com/geldata/gel-cli) CLI. We will miss it.
Project-scoped PostgreSQL instance manager for local development.
## Overview
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.
Isolate, upgrade and nuke -- everything safely.
## Why Use pgd?
**Stop playing around database -- play with it.** Your legacy project needs Postgres 14, your new microservice wants 16, and that experimental side project is testing 19-beta. With `pgd`, they all run simultaneously without stepping on each other's toes.
**Onboard developers in seconds, not hours.** No more wiki pages with 47 steps to set up the local database. New teammate clones the repo, runs `pgd init`, and they're ready to code. The database config lives right there in version control where it belongs.
**Isolate your data like you isolate your code (or your life).** Each project gets its own database instance.
**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)
## Installation
Install via cargo:
```bash
cargo install pgd
```
## Quick Start
Navigate to your project directory and initialize a new PostgreSQL instance:
```bash
cd my-project
pgd init
```
This creates a `pgd.toml` configuration file with auto-generated credentials and latests postgres version available.
Note: upgrades are currently unsupported at the moment.
Downgrades wouldn't ever be supported, because postgres is not future-compatible.
## Commands
### Project Initialization
```bash
pgd init
```
Creates a `pgd.toml` file in the current directory with auto-populated configuration. If the file already exists, initializes the Docker container for the existing configuration.
### Instance Control
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
# View PostgreSQL logs
pgd instance logs
# Follow logs in real-time
pgd instance logs --follow
# Get connection details
pgd instance conn
# Get connection as DSN URL
pgd instance conn --format dsn
# Get human-readable connection details
pgd instance conn --format human
```
### Destructive Operations
```bash
# Stop and remove the container
pgd instance destroy
# Wipe all database data
pgd instance wipe
```
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
```bash
# Enable verbose logging
pgd --verbose <command>
# Show version
pgd --version
# Show help
pgd --help
```
## How It Works
`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 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
└── ...
```
The `pgd.toml` file should be committed to version control so team members can reproduce the exact database setup.

View File

@@ -1,3 +0,0 @@
version = "18.1"
password = "a7BASi7P3gCgc0Xx"
port = 5433

30
release.toml Normal file
View File

@@ -0,0 +1,30 @@
allow-branch = [
"*",
]
sign-commit = false
sign-tag = false
push-remote = "origin"
release = true
publish = true
verify = true
owners = []
push = true
push-options = []
consolidate-commits = false
pre-release-commit-message = "chore: Release {{crate_name}} version {{version}}"
pre-release-replacements = []
tag-message = "chore: Release {{crate_name}} version {{version}}"
tag-name = "{{prefix}}v{{version}}"
tag = true
enable-features = []
enable-all-features = false
dependent-version = "upgrade"
metadata = "optional"
certs-source = "webpki"
[unstable]
workspace-publish = false
[rate-limit]
new-packages = 5
existing-packages = 30

View File

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

View File

@@ -8,25 +8,23 @@ 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 {
/// Human-readable text format /// DSN Url
Text, Dsn,
/// JSON format // Human readable format
Json, Human,
/// Environment variable format
Env,
} }
#[derive(Subcommand)] #[derive(Subcommand)]
@@ -38,13 +36,22 @@ pub enum ControlCommands {
/// Restart postgres instance /// Restart postgres instance
Restart, Restart,
/// (WARNING!) Destroy postgres instance /// (WARNING!) Destroy postgres instance
Destroy, Destroy { force: bool },
/// (WARNING!) Destruct database
Wipe { force: bool },
/// Status of instance /// Status of instance
Status, Status,
/// View logs produced by postgres /// View logs produced by postgres
Logs { follow: bool }, Logs {
#[arg(short, long, default_value = "false")]
follow: bool,
},
/// (Sensitive) get connection details /// (Sensitive) get connection details
Connection { format: ConnectionFormat }, Conn {
#[arg(short, long, default_value = "dsn")]
format: ConnectionFormat,
},
} }
#[derive(Subcommand)] #[derive(Subcommand)]

2
src/consts.rs Normal file
View File

@@ -0,0 +1,2 @@
pub const USERNAME: &str = "postgres";
pub const DATABASE: &str = "postgres";

View File

@@ -1,47 +1,409 @@
use std::time::Duration; use miette::miette;
use miette::{bail, 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 miette::Result; use futures::TryStreamExt;
use miette::{IntoDiagnostic, Result};
use crate::{ use crate::{
config::{PGDConfig, PostgresVersion, Project}, cli::ConnectionFormat,
controller::docker::DockerController, config::{PGDConfig, Project},
consts::{DATABASE, USERNAME},
controller::{docker::DockerController, reconciler::Reconciler},
state::{InstanceState, StateManager}, state::{InstanceState, StateManager},
}; };
mod docker; mod docker;
mod utils; mod utils;
const MAX_RETRIES: u32 = 10; pub mod reconciler;
const VERIFY_DURATION_SECS: u64 = 5;
pub struct Controller { pub struct Context {
docker: DockerController, docker: DockerController,
project: Option<Project>, project: Option<Project>,
#[allow(unused)] instance: Option<InstanceState>,
state: StateManager, state: StateManager,
} }
impl Controller { impl Context {
pub async fn new() -> Result<Self> { pub fn require_instance(&self) -> Result<&InstanceState> {
self.instance.as_ref().ok_or(miette!("This command requires instance. Either initiliaze a project, or pass -I with instance name"))
}
pub fn require_project(&self) -> Result<&Project> {
self.project.as_ref().ok_or(miette!(
"This command requires project. Please, initiliaze a project."
))
}
pub async fn new(instance_override: Option<String>) -> Result<Self> {
let project = Project::load()?;
let state = StateManager::new()?;
let instance = match (project.as_ref(), instance_override) {
(None, None) => None,
// prioritizing provided instance name
(_, Some(instance)) => state.get(&instance),
(Some(project), None) => state.get(&project.name),
};
Ok(Self { Ok(Self {
docker: DockerController::new().await?, docker: DockerController::new().await?,
project: Project::load()?, project,
state: StateManager::load()?, instance,
state,
}) })
} }
}
/// Main CLI command dispatcher
pub struct Controller {
ctx: Context,
}
impl Controller {
pub fn new(ctx: Context) -> Self {
Self { ctx }
}
pub async fn logs(&self, follow: bool) -> Result<()> {
let instance = self.ctx.require_instance()?;
let mut logs = self
.ctx
.docker
.stream_logs(&instance.container_id, follow)
.await;
while let Some(log) = logs.try_next().await? {
let bytes = log.into_bytes();
let line = String::from_utf8_lossy(bytes.as_ref());
print!("{line}");
}
Ok(())
}
pub async fn show_connection(&self, format: ConnectionFormat) -> Result<()> {
let project = self.ctx.require_project()?;
let reconciler = Reconciler { ctx: &self.ctx };
reconciler.reconcile(project).await?;
match format {
ConnectionFormat::Dsn => {
println!(
"postgres://{}:{}@127.0.0.1:{}/{}",
USERNAME, project.config.password, project.config.port, DATABASE
);
}
ConnectionFormat::Human => {
format_conn_human(project);
}
}
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<()> {
if let Some(project) = &self.project { let reconciler = Reconciler { ctx: &self.ctx };
return self.reconcile(project).await;
if let Some(project) = &self.ctx.project {
return reconciler.reconcile(project).await;
} }
println!("{}", "Initializing new pgd project...".cyan()); println!("{}", "Initializing new pgd project...".cyan());
let mut versions = self.docker.available_versions().await?; let mut versions = self.ctx.docker.available_versions().await?;
versions.sort(); versions.sort();
let latest_version = versions let latest_version = versions
.last() .last()
@@ -50,30 +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 = Table::new(); let mut table = create_ui_table("Project Configuration".to_string());
table
.load_preset(UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_style(comfy_table::TableComponent::MiddleIntersections, ' ')
.set_header(vec![
Cell::new("Instance Configuration").add_attribute(Attribute::Bold),
]);
use comfy_table::TableComponent::*;
table.set_style(TopLeftCorner, '╭');
table.set_style(TopRightCorner, '╮');
table.set_style(BottomLeftCorner, '╰');
table.set_style(BottomRightCorner, '╯');
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),
@@ -93,217 +441,61 @@ impl Controller {
println!("{table}"); println!("{table}");
self.reconcile(&project).await?; reconciler.reconcile(&project).await?;
println!("\n{}", "✓ Project initialized successfully!".green().bold());
Ok(())
}
pub async fn reconcile(&self, project: &Project) -> Result<()> {
self.docker
.ensure_version_downloaded(&project.config.version)
.await?;
self.ensure_container_running(project).await?;
Ok(())
}
async fn ensure_container_running(&self, project: &Project) -> Result<()> {
let mut state = StateManager::load()?;
let instance_state = state.get_mut(&project.name);
let container_id = match instance_state {
Some(instance) => match self.ensure_container_exists(instance).await? {
Some(id) => id,
None => self.update_project_container(project, &mut state).await?,
},
None => self.update_project_container(project, &mut state).await?,
};
let container_version = self
.docker
.get_container_postgres_version(&container_id)
.await?;
self.ensure_matches_project_version(project, &mut state, &container_id, container_version)
.await?;
if self
.docker
.is_container_running_by_id(&container_id)
.await?
{
println!("{}", "Container is already running".white());
return Ok(());
}
use indicatif::{ProgressBar, ProgressStyle};
let spinner = ProgressBar::new_spinner();
spinner.enable_steady_tick(Duration::from_millis(100));
spinner.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.cyan} {msg}")
.unwrap(),
);
spinner.set_message("Starting container...");
for attempt in 1..=MAX_RETRIES {
spinner.set_message(format!(
"Starting container (attempt {}/{})",
attempt, MAX_RETRIES
));
let result = self.try_starting_container(&container_id, &spinner).await;
match result {
Ok(_) => {
spinner.finish_with_message(format!(
"{}",
"Container started successfully".green().bold()
));
return Ok(());
}
Err(err) => {
spinner.set_message(format!(
"{} {}/{} failed: {}",
"Attempt".yellow(),
attempt,
MAX_RETRIES,
err
));
}
}
if attempt < MAX_RETRIES {
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
}
}
spinner.finish_with_message(format!("{}", "Failed to start container".red()));
miette::bail!("Failed to start container after {} attempts", MAX_RETRIES)
}
async fn try_starting_container(
&self,
container_id: &String,
spinner: &indicatif::ProgressBar,
) -> Result<(), miette::Error> {
match self.docker.start_container_by_id(container_id).await {
Ok(_) => {
spinner.set_message(format!(
"{} ({}s)...",
"Verifying container is running".cyan(),
VERIFY_DURATION_SECS
));
for i in 0..VERIFY_DURATION_SECS {
spinner.set_message(format!(
"{} ({}/{}s)",
"Verifying container stability".cyan(),
i + 1,
VERIFY_DURATION_SECS
));
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
}
if self.docker.is_container_running_by_id(container_id).await? {
return Ok(());
} else {
miette::bail!("Container stopped unexpectedly after start");
}
}
Err(e) => {
miette::bail!("Failed to start: {}", e);
}
}
}
async fn update_project_container(
&self,
project: &Project,
state: &mut StateManager,
) -> Result<String, miette::Error> {
println!( println!(
"{} {}", "\n{} {}",
"Creating container".cyan(), "".green().bold(),
project.container_name().yellow() "Project initialized successfully!".green(),
); );
let id = self
.docker
.create_postgres_container(
&project.container_name(),
&project.config.version,
&project.config.password,
project.config.port,
)
.await?;
println!("{}", "Container created successfully".green());
state.set(
project.name.clone(),
crate::state::InstanceState::new(
id.clone(),
project.config.version,
project.config.port,
),
);
state.save()?;
Ok(id)
}
async fn ensure_container_exists(
&self,
instance: &InstanceState,
) -> Result<Option<String>, miette::Error> {
let mut container_id = None;
let id = &instance.container_id;
if self.docker.container_exists_by_id(id).await? {
container_id = Some(id.clone());
}
Ok(container_id)
}
async fn ensure_matches_project_version(
&self,
project: &Project,
_state: &mut StateManager,
_container_id: &String,
container_version: PostgresVersion,
) -> Result<(), miette::Error> {
let _: () = if container_version != project.config.version {
let needs_upgrade = container_version < project.config.version;
if needs_upgrade {
bail!("Upgrades are currently unsupported! :(");
// println!(
// "Upgrading PostgreSQL from {} to {}...",
// container_version, project.config.version
// );
// self.docker.stop_container(container_id, 10).await?;
// self.docker
// .upgrade_container_image(
// container_id,
// container_name,
// &project.config.version,
// &project.config.password,
// project.config.port,
// )
// .await?;
// if let Some(instance_state) = state.get_mut(&project.name) {
// instance_state.postgres_version = project.config.version.to_string();
// state.save()?;
// }
} else {
miette::bail!(
"Cannot downgrade PostgreSQL from {} to {}. Downgrades are not supported.",
container_version,
project.config.version
);
}
};
Ok(()) Ok(())
} }
} }
fn format_conn_human(project: &Project) {
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),
]);
table.add_row(vec![
Cell::new("PostgreSQL Version").fg(Color::White),
Cell::new(project.config.version.to_string()).add_attribute(Attribute::Bold),
]);
table.add_row(vec![
Cell::new("Host").fg(Color::White),
Cell::new("127.0.0.1").add_attribute(Attribute::Bold),
]);
table.add_row(vec![
Cell::new("Port").fg(Color::White),
Cell::new(project.config.port.to_string()).add_attribute(Attribute::Bold),
]);
table.add_row(vec![
Cell::new("Username").fg(Color::White),
Cell::new(USERNAME).add_attribute(Attribute::Bold),
]);
table.add_row(vec![
Cell::new("Password").fg(Color::White),
Cell::new(project.config.password.clone()).fg(Color::DarkGrey),
]);
println!("{}", table);
}
fn create_ui_table(header: String) -> Table {
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_style(comfy_table::TableComponent::MiddleIntersections, ' ')
.set_header(vec![Cell::new(header).add_attribute(Attribute::Bold)]);
use comfy_table::TableComponent::*;
table.set_style(TopLeftCorner, '╭');
table.set_style(TopRightCorner, '╮');
table.set_style(BottomLeftCorner, '╰');
table.set_style(BottomRightCorner, '╯');
table
}

View File

@@ -1,18 +1,21 @@
use miette::miette; use miette::{Diagnostic, miette};
use std::str::FromStr; use std::str::FromStr;
use thiserror::Error;
use bollard::{ use bollard::{
Docker, Docker,
container::LogOutput,
query_parameters::{ query_parameters::{
CreateContainerOptions, CreateImageOptions, InspectContainerOptions, ListImagesOptions, CreateContainerOptions, CreateImageOptions, InspectContainerOptions, ListImagesOptions,
StartContainerOptions, StopContainerOptions, LogsOptions, StartContainerOptions, StopContainerOptions,
}, },
secret::ContainerCreateBody, secret::ContainerCreateBody,
}; };
use colored::Colorize; use colored::Colorize;
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,
@@ -26,6 +29,11 @@ fn format_image(ver: &PostgresVersion) -> String {
format!("{DOCKERHUB_POSTGRES}:{}", ver) format!("{DOCKERHUB_POSTGRES}:{}", ver)
} }
#[derive(Error, Debug, Diagnostic)]
#[error("Docker operation failed")]
#[diagnostic(code(pgd::docker))]
pub enum Error {}
pub struct DockerController { pub struct DockerController {
daemon: Docker, daemon: Docker,
} }
@@ -38,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()))
@@ -262,4 +270,102 @@ impl DockerController {
PostgresVersion::from_str(version_str) PostgresVersion::from_str(version_str)
.map_err(|_| miette!("Invalid version in label: {}", version_str)) .map_err(|_| miette!("Invalid version in label: {}", version_str))
} }
pub async fn stream_logs(
&self,
container_id: &str,
follow: bool,
) -> impl Stream<Item = Result<LogOutput>> {
let options = Some(LogsOptions {
follow,
stdout: true,
stderr: true,
..Default::default()
});
self.daemon
.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)
}
} }

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

@@ -0,0 +1,242 @@
use std::time::Duration;
use miette::{Diagnostic, bail};
use colored::Colorize;
use miette::Result;
use thiserror::Error;
use tracing::info;
use crate::{
config::{PostgresVersion, Project},
controller::{
Context,
docker::{self},
},
state::InstanceState,
};
const MAX_RETRIES: usize = 10;
const VERIFY_DURATION_SECS: u64 = 5;
#[derive(Error, Debug, Diagnostic)]
#[error("Failed to sync container state")]
#[diagnostic(code(pgd::reconcile))]
pub enum ReconcileError {
AlreadyRunning,
ImageDownload(#[source] docker::Error),
}
pub struct Reconciler<'a> {
pub ctx: &'a Context,
}
impl<'a> Reconciler<'a> {
pub async fn reconcile(&self, project: &Project) -> Result<()> {
self.ctx
.docker
.ensure_version_downloaded(&project.config.version)
.await?;
self.ensure_container_running(project).await?;
Ok(())
}
async fn ensure_container_running(&self, project: &Project) -> Result<()> {
let container_id = match &self.ctx.instance {
Some(instance) => match self.ensure_container_exists(instance).await? {
Some(id) => id,
None => self.update_project_container(project).await?,
},
None => self.update_project_container(project).await?,
};
let container_version = self
.ctx
.docker
.get_container_postgres_version(&container_id)
.await?;
self.ensure_matches_project_version(project, &container_id, container_version)
.await?;
if self
.ctx
.docker
.is_container_running_by_id(&container_id)
.await?
{
info!("Container is already running");
return Ok(());
}
use indicatif::{ProgressBar, ProgressStyle};
let spinner = ProgressBar::new_spinner();
spinner.enable_steady_tick(Duration::from_millis(100));
spinner.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.cyan} {msg}")
.unwrap(),
);
spinner.set_message("Starting container...");
for attempt in 1..=MAX_RETRIES {
spinner.set_message(format!(
"Starting container (attempt {}/{})",
attempt, MAX_RETRIES
));
let result = self.try_starting_container(&container_id, &spinner).await;
match result {
Ok(_) => {
spinner.finish_with_message(format!(
"{}",
"Container started successfully".green().bold()
));
return Ok(());
}
Err(err) => {
spinner.set_message(format!(
"{} {}/{} failed: {}",
"Attempt".yellow(),
attempt,
MAX_RETRIES,
err
));
}
}
if attempt < MAX_RETRIES {
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
}
}
spinner.finish_with_message(format!("{}", "Failed to start container".red()));
miette::bail!("Failed to start container after {} attempts", MAX_RETRIES)
}
async fn try_starting_container(
&self,
container_id: &str,
spinner: &indicatif::ProgressBar,
) -> Result<(), miette::Error> {
match self.ctx.docker.start_container_by_id(container_id).await {
Ok(_) => {
spinner.set_message(format!(
"{} ({}s)...",
"Verifying container is running".cyan(),
VERIFY_DURATION_SECS
));
for i in 0..VERIFY_DURATION_SECS {
spinner.set_message(format!(
"{} ({}/{}s)",
"Verifying container stability".cyan(),
i + 1,
VERIFY_DURATION_SECS
));
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
}
if self
.ctx
.docker
.is_container_running_by_id(container_id)
.await?
{
Ok(())
} else {
miette::bail!("Container stopped unexpectedly after start");
}
}
Err(e) => {
miette::bail!("Failed to start: {}", e);
}
}
}
async fn update_project_container(&self, project: &Project) -> Result<String, miette::Error> {
info!(
"{} {}",
"Creating container".cyan(),
project.container_name().yellow()
);
let id = self
.ctx
.docker
.create_postgres_container(
&project.container_name(),
&project.config.version,
&project.config.password,
project.config.port,
)
.await?;
info!("{}", "Container created successfully".green());
self.ctx.state.upsert(
project.name.clone(),
crate::state::InstanceState::new(
id.clone(),
project.config.version,
project.config.port,
),
);
self.ctx.state.save()?;
Ok(id)
}
async fn ensure_container_exists(
&self,
instance: &InstanceState,
) -> Result<Option<String>, miette::Error> {
let mut container_id = None;
let id = &instance.container_id;
if self.ctx.docker.container_exists_by_id(id).await? {
container_id = Some(id.clone());
}
Ok(container_id)
}
async fn ensure_matches_project_version(
&self,
project: &Project,
_container_id: &String,
container_version: PostgresVersion,
) -> Result<(), miette::Error> {
let _: () = if container_version != project.config.version {
let needs_upgrade = container_version < project.config.version;
if needs_upgrade {
bail!("Upgrades are currently unsupported! :(");
// println!(
// "Upgrading PostgreSQL from {} to {}...",
// container_version, project.config.version
// );
// self.docker.stop_container(container_id, 10).await?;
// self.docker
// .upgrade_container_image(
// container_id,
// container_name,
// &project.config.version,
// &project.config.password,
// project.config.port,
// )
// .await?;
// if let Some(instance_state) = state.get_mut(&project.name) {
// instance_state.postgres_version = project.config.version.to_string();
// state.save()?;
// }
} else {
miette::bail!(
"Cannot downgrade PostgreSQL from {} to {}. Downgrades are not supported.",
container_version,
project.config.version
);
}
};
Ok(())
}
}

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

@@ -2,40 +2,59 @@ mod cli;
mod config; mod config;
mod state; mod state;
mod consts { mod consts;
pub const USERNAME: &str = "postgres";
pub const DATABASE: &str = "postgres";
}
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::controller::Controller; use crate::{
cli::ControlCommands,
controller::{Context, Controller},
};
#[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");
let controller = Controller::new().await?;
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 => controller.init_project().await?, cli::Commands::Init => {
cli::Commands::Instance { name, cmd } => todo!(), do_cmd!(None, init_project);
}
cli::Commands::Instance { name, cmd } => match cmd {
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 } => do_cmd!(None, show_connection, format),
ControlCommands::Wipe { force } => do_cmd!(name, wipe, force),
},
} }
Ok(()) Ok(())
} }
fn init_tracing(verbose: bool) { fn init_tracing(verbosity: Verbosity) {
tracing_subscriber::fmt()
.with_max_level(verbosity)
tracing_subscriber::fmt::init(); .without_time()
.with_target(false)
.init();
} }

View File

@@ -1,80 +1,55 @@
use miette::{Context, IntoDiagnostic, Result}; use miette::{Context, IntoDiagnostic, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::cell::RefCell;
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
use crate::config::PostgresVersion; use crate::config::PostgresVersion;
/// State information for a single PostgreSQL instance
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstanceState { pub struct InstanceState {
/// Docker container ID
pub container_id: String, pub container_id: String,
/// PostgreSQL version running in the container
pub postgres_version: PostgresVersion, pub postgres_version: PostgresVersion,
/// Port the container is bound to
pub port: u16, pub port: u16,
/// Timestamp when the instance was created (Unix timestamp)
pub created_at: u64, pub created_at: u64,
} }
/// Manages the global state file at ~/.pgd/state.json #[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[derive(Debug, Clone, Serialize, Deserialize)] struct State {
pub struct StateManager {
/// Map of project name to instance state
#[serde(default)] #[serde(default)]
instances: HashMap<String, InstanceState>, instances: HashMap<String, InstanceState>,
} }
impl State {
/// Get the path to the state file (~/.pgd/state.json) fn new() -> Result<Self> {
fn state_file_path() -> Result<PathBuf> {
let home = std::env::var("HOME")
.into_diagnostic()
.wrap_err("Failed to get HOME environment variable")?;
Ok(PathBuf::from(home).join(".pgd").join("state.json"))
}
impl StateManager {
/// Load the state manager from disk, or create a new one if it doesn't exist
pub fn load() -> Result<Self> {
let state_path = state_file_path()?; let state_path = state_file_path()?;
if !state_path.exists() { if !state_path.exists() {
// Create the directory if it doesn't exist
if let Some(parent) = state_path.parent() { if let Some(parent) = state_path.parent() {
std::fs::create_dir_all(parent) std::fs::create_dir_all(parent)
.into_diagnostic() .into_diagnostic()
.wrap_err("Failed to create .pgd directory")?; .wrap_err("Failed to create .pgd directory")?;
} }
// Return empty state return Ok(Self::default());
return Ok(StateManager {
instances: HashMap::new(),
});
} }
let content = std::fs::read_to_string(&state_path) let content = std::fs::read_to_string(&state_path)
.into_diagnostic() .into_diagnostic()
.wrap_err_with(|| format!("Failed to read state file: {}", state_path.display()))?; .wrap_err_with(|| format!("Failed to read state file: {}", state_path.display()))?;
let state: StateManager = serde_json::from_str(&content) let state: Self = serde_json::from_str(&content)
.into_diagnostic() .into_diagnostic()
.wrap_err("Failed to parse state.json")?; .wrap_err("Failed to parse state.json")?;
Ok(state) Ok(state)
} }
/// Save the state manager to disk fn save(&self) -> Result<()> {
pub fn save(&self) -> Result<()> {
let state_path = state_file_path()?; let state_path = state_file_path()?;
// Ensure directory exists
if let Some(parent) = state_path.parent() { if let Some(parent) = state_path.parent() {
std::fs::create_dir_all(parent) std::fs::create_dir_all(parent)
.into_diagnostic() .into_diagnostic()
@@ -91,20 +66,34 @@ impl StateManager {
Ok(()) Ok(())
} }
/// Get mutable state for a specific project
pub fn get_mut(&mut self, project_name: &str) -> Option<&mut InstanceState> {
self.instances.get_mut(project_name)
} }
/// Set the state for a specific project pub struct StateManager(RefCell<State>);
pub fn set(&mut self, project_name: String, state: InstanceState) {
self.instances.insert(project_name, state); impl StateManager {
pub fn new() -> Result<Self> {
Ok(Self(RefCell::new(State::new()?)))
} }
/// Remove the state for a specific project pub fn save(&self) -> Result<()> {
pub fn remove(&mut self, project_name: &str) -> Option<InstanceState> { self.0.borrow().save()?;
self.instances.remove(project_name) Ok(())
}
pub fn get(&self, project_name: &str) -> Option<InstanceState> {
self.0.borrow().instances.get(project_name).cloned()
}
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()
} }
} }
@@ -123,3 +112,9 @@ impl InstanceState {
} }
} }
} }
fn state_file_path() -> Result<PathBuf> {
let home = std::env::home_dir().wrap_err("Failed to get HOME environment variable")?;
Ok(home.join(".pgd").join("state.json"))
}