Compare commits

..

5 Commits

Author SHA1 Message Date
hdbg
bc37b58d80 feat(ui): add colored output and tables for project info 2025-12-04 21:28:26 +01:00
hdbg
bf0ae1b3ee chore: add project metadata and MIT license 2025-12-04 18:59:49 +01:00
hdbg
ddb28085b9 refactor(state): remove unused methods and parameters 2025-12-04 18:57:34 +01:00
hdbg
71f363fde9 misc: rename to 'pgd' 2025-12-04 18:55:10 +01:00
hdbg
6e2ee650d8 feat: added progress bar for container launch 2025-12-04 18:52:21 +01:00
13 changed files with 329 additions and 158 deletions

View File

@@ -4,27 +4,27 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## Project Overview
`pgx` is a CLI tool for managing project-scoped PostgreSQL instances running in Docker containers. Each project gets its own isolated Postgres instance, managed through a `pgx.toml` configuration file. `pgd` is a CLI tool for managing project-scoped PostgreSQL instances running in Docker containers. Each project gets its own isolated Postgres instance, managed through a `pgd.toml` configuration file.
## Core Architecture ## Core Architecture
### Project-Oriented Design ### Project-Oriented Design
- Each project has a `pgx.toml` file at its root that defines the Postgres configuration - Each project has a `pgd.toml` file at its root that defines the Postgres configuration
- The project name is derived from the directory containing `pgx.toml` - The project name is derived from the directory containing `pgd.toml`
- Each project gets its own Docker container - Each project gets its own Docker container
- State is tracked separately per instance to detect configuration drift - State is tracked separately per instance to detect configuration drift
### Configuration Management ### Configuration Management
The `pgx.toml` file stores: The `pgd.toml` file stores:
- `postgres_version`: PostgreSQL version to use - `postgres_version`: PostgreSQL version to use
- `database_name`: Name of the database - `database_name`: Name of the database
- `user_name`: Database user - `user_name`: Database user
- `password`: Database password - `password`: Database password
- `port`: Host port to bind (auto-selected from available ports) - `port`: Host port to bind (auto-selected from available ports)
Values are auto-populated during `pgx init` with sensible defaults or random values where appropriate. Values are auto-populated during `pgd init` with sensible defaults or random values where appropriate.
### State Tracking ### State Tracking
@@ -36,7 +36,7 @@ The tool maintains separate state for each instance to detect configuration drif
## Key Dependencies ## Key Dependencies
- **clap** (with derive feature): CLI argument parsing and command structure - **clap** (with derive feature): CLI argument parsing and command structure
- **toml**: Parsing and serializing `pgx.toml` configuration files - **toml**: Parsing and serializing `pgd.toml` configuration files
- **bollard**: Docker daemon interaction for container lifecycle management - **bollard**: Docker daemon interaction for container lifecycle management
- **tokio** (with full feature set): Async runtime for Docker operations - **tokio** (with full feature set): Async runtime for Docker operations
- **tracing** + **tracing-subscriber**: Structured logging throughout the application - **tracing** + **tracing-subscriber**: Structured logging throughout the application
@@ -80,15 +80,15 @@ cargo fmt -- --check # Check without modifying
The CLI follows this pattern: The CLI follows this pattern:
``` ```
pgx <command> [options] pgd <command> [options]
``` ```
Key commands to implement: Key commands to implement:
- `pgx init`: Create pgx.toml with auto-populated configuration - `pgd init`: Create pgd.toml with auto-populated configuration
- `pgx start`: Start the Postgres container for current project - `pgd start`: Start the Postgres container for current project
- `pgx stop`: Stop the running container - `pgd stop`: Stop the running container
- `pgx status`: Show instance status and detect drift - `pgd status`: Show instance status and detect drift
- `pgx destroy`: Remove container and clean up - `pgd destroy`: Remove container and clean up
## Implementation Notes ## Implementation Notes

155
Cargo.lock generated
View File

@@ -255,6 +255,26 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "colored"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e"
dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "comfy-table"
version = "7.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b03b7db8e0b4b2fdad6c551e634134e99ec000e5c8c3b6856c65e8bbaded7a3b"
dependencies = [
"crossterm",
"unicode-segmentation",
"unicode-width 0.2.2",
]
[[package]] [[package]]
name = "console" name = "console"
version = "0.16.1" version = "0.16.1"
@@ -274,6 +294,29 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "crossterm"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
dependencies = [
"bitflags",
"crossterm_winapi",
"document-features",
"parking_lot",
"rustix",
"winapi",
]
[[package]]
name = "crossterm_winapi"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
dependencies = [
"winapi",
]
[[package]] [[package]]
name = "darling" name = "darling"
version = "0.21.3" version = "0.21.3"
@@ -330,6 +373,15 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]] [[package]]
name = "dyn-clone" name = "dyn-clone"
version = "1.0.20" version = "1.0.20"
@@ -849,6 +901,12 @@ version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.14" version = "0.4.14"
@@ -1001,11 +1059,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]] [[package]]
name = "pgx" name = "pgd"
version = "0.1.0" version = "0.0.1"
dependencies = [ dependencies = [
"bollard", "bollard",
"clap", "clap",
"colored",
"comfy-table",
"futures", "futures",
"indicatif", "indicatif",
"miette", "miette",
@@ -1876,13 +1936,22 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.60.2" version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [ dependencies = [
"windows-targets", "windows-targets 0.53.5",
] ]
[[package]] [[package]]
@@ -1894,6 +1963,22 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]] [[package]]
name = "windows-targets" name = "windows-targets"
version = "0.53.5" version = "0.53.5"
@@ -1901,58 +1986,106 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [ dependencies = [
"windows-link", "windows-link",
"windows_aarch64_gnullvm", "windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc", "windows_aarch64_msvc 0.53.1",
"windows_i686_gnu", "windows_i686_gnu 0.53.1",
"windows_i686_gnullvm", "windows_i686_gnullvm 0.53.1",
"windows_i686_msvc", "windows_i686_msvc 0.53.1",
"windows_x86_64_gnu", "windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm", "windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc", "windows_x86_64_msvc 0.53.1",
] ]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]] [[package]]
name = "windows_aarch64_gnullvm" name = "windows_aarch64_gnullvm"
version = "0.53.1" version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.53.1" version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.53.1" version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]] [[package]]
name = "windows_i686_gnullvm" name = "windows_i686_gnullvm"
version = "0.53.1" version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.53.1" version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.53.1" version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.53.1" version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.53.1" version = "0.53.1"

View File

@@ -1,11 +1,16 @@
[package] [package]
name = "pgx" name = "pgd"
version = "0.1.0" version = "0.0.1"
edition = "2024" edition = "2024"
description = "CLI to manage postgres instances for local development"
repository = "https://github.com/MarketTakers/pgd"
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"] }
colored = "3.0.0"
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"] }

21
LICENSE.md Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 MarketTakers
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

3
pgd.toml Normal file
View File

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

View File

@@ -1,3 +0,0 @@
version = "18.1"
password = "P8t42UQ53iIza2Ic"
port = 5432

View File

@@ -1,7 +1,7 @@
_______ _______ __ __ ▄▄▄▄ ▐▌
| || || |_| | █ █ ▐▌
| _ || ___|| | █▄▄▄▀ ▗▞▀▜▌
| |_| || | __ | | ▗▄▖▝▚▄▟▌
| ___|| || | | | ▐▌ ▐▌
| | | |_| || _ | ▝▀▜▌
|___| |_______||__| |__| ▐▙▄▞▘

View File

@@ -7,7 +7,7 @@ const STYLES: styling::Styles = styling::Styles::styled()
.placeholder(styling::AnsiColor::Cyan.on_default()); .placeholder(styling::AnsiColor::Cyan.on_default());
#[derive(Parser)] #[derive(Parser)]
#[command(name = "pgx")] #[command(name = "pgd")]
#[command(about = "Project-scoped PostgreSQL instance manager", long_about = None)] #[command(about = "Project-scoped PostgreSQL instance manager", long_about = None)]
#[command(version)] #[command(version)]
#[command(styles = STYLES)] #[command(styles = STYLES)]
@@ -51,8 +51,6 @@ pub enum ControlCommands {
pub enum Commands { pub enum Commands {
/// Create a new project, or initialize instance for existing one /// Create a new project, or initialize instance for existing one
Init, Init,
/// Create a new project, or initialize instance for existing one
Sync,
/// Start the PostgreSQL container for the current project /// Start the PostgreSQL container for the current project
Instance { Instance {

View File

@@ -35,12 +35,12 @@ impl Display for PostgresVersion {
} }
} }
const PROJECT_FILENAME: &str = "pgx.toml"; const PROJECT_FILENAME: &str = "pgd.toml";
/// Configuration stored in pgx.toml /// Configuration stored in pgd.toml
#[serde_as] #[serde_as]
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PgxConfig { pub struct PGDConfig {
/// PostgreSQL version to use /// PostgreSQL version to use
#[serde_as(as = "DisplayFromStr")] #[serde_as(as = "DisplayFromStr")]
pub version: PostgresVersion, pub version: PostgresVersion,
@@ -52,16 +52,16 @@ pub struct PgxConfig {
pub port: u16, pub port: u16,
} }
impl PgxConfig { impl PGDConfig {
pub fn load(path: impl AsRef<Path>) -> Result<Self> { pub fn load(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref(); let path = path.as_ref();
let content = std::fs::read_to_string(path) let content = std::fs::read_to_string(path)
.into_diagnostic() .into_diagnostic()
.wrap_err_with(|| format!("Failed to read config file: {}", path.display()))?; .wrap_err_with(|| format!("Failed to read config file: {}", path.display()))?;
let config: PgxConfig = toml::from_str(&content) let config: PGDConfig = toml::from_str(&content)
.into_diagnostic() .into_diagnostic()
.wrap_err("Failed to parse pgx.toml")?; .wrap_err("Failed to parse pgd.toml")?;
Ok(config) Ok(config)
} }
@@ -85,16 +85,16 @@ pub struct Project {
/// Project name (derived from directory name) /// Project name (derived from directory name)
pub name: String, pub name: String,
/// Path to the project directory containing pgx.toml /// Path to the project directory containing pgd.toml
pub path: PathBuf, pub path: PathBuf,
pub config: PgxConfig, pub config: PGDConfig,
} }
impl Project { impl Project {
pub fn container_name(&self) -> String { pub fn container_name(&self) -> String {
let container_name = format!( let container_name = format!(
"pgx-{}-{}", "pgd-{}-{}",
self.name, self.name,
self.config.version.to_string().replace('.', "_") self.config.version.to_string().replace('.', "_")
); );
@@ -110,7 +110,7 @@ impl Project {
return Ok(None); return Ok(None);
} }
let config = PgxConfig::load(&config_path)?; let config = PGDConfig::load(&config_path)?;
let name = Self::extract_project_name(&project_path)?; let name = Self::extract_project_name(&project_path)?;
Ok(Some(Project { Ok(Some(Project {
@@ -120,7 +120,7 @@ impl Project {
})) }))
} }
pub fn new(config: PgxConfig) -> Result<Self> { pub fn new(config: PGDConfig) -> Result<Self> {
let project_path = get_project_path()?; let project_path = get_project_path()?;
let name = Self::extract_project_name(&project_path)?; let name = Self::extract_project_name(&project_path)?;
@@ -143,9 +143,9 @@ impl Project {
.ok_or_else(|| miette::miette!("Failed to extract project name from path")) .ok_or_else(|| miette::miette!("Failed to extract project name from path"))
} }
/// Get the path to the pgx.toml file /// Get the path to the pgd.toml file
pub fn config_path(&self) -> PathBuf { pub fn config_path(&self) -> PathBuf {
self.path.join("pgx.toml") self.path.join("pgd.toml")
} }
/// Save the current configuration /// Save the current configuration

View File

@@ -1,9 +1,13 @@
use std::time::Duration;
use miette::{bail, miette}; use miette::{bail, miette};
use colored::Colorize;
use comfy_table::{Attribute, Cell, Color, ContentArrangement, Table, presets::UTF8_FULL};
use miette::Result; use miette::Result;
use crate::{ use crate::{
config::{PgxConfig, PostgresVersion, Project}, config::{PGDConfig, PostgresVersion, Project},
controller::docker::DockerController, controller::docker::DockerController,
state::{InstanceState, StateManager}, state::{InstanceState, StateManager},
}; };
@@ -12,11 +16,12 @@ mod docker;
mod utils; mod utils;
const MAX_RETRIES: u32 = 10; const MAX_RETRIES: u32 = 10;
const VERIFY_DURATION_SECS: u64 = 10; const VERIFY_DURATION_SECS: u64 = 5;
pub struct Controller { pub struct Controller {
docker: DockerController, docker: DockerController,
project: Option<Project>, project: Option<Project>,
#[allow(unused)]
state: StateManager, state: StateManager,
} }
@@ -34,7 +39,7 @@ impl Controller {
return self.reconcile(project).await; return self.reconcile(project).await;
} }
println!("Initializing new pgx project..."); println!("{}", "Initializing new pgd project...".cyan());
let mut versions = self.docker.available_versions().await?; let mut versions = self.docker.available_versions().await?;
versions.sort(); versions.sort();
@@ -42,22 +47,55 @@ impl Controller {
.last() .last()
.ok_or(miette!("expected to have at least one version"))?; .ok_or(miette!("expected to have at least one version"))?;
let config = PgxConfig { 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()?,
}; };
let project = Project::new(config)?; let project = Project::new(config)?;
println!("Created pgx.toml in {}", project.path.display()); println!(
println!(" Project: {}", project.name); "\n{} {}\n",
println!(" PostgreSQL version: {}", project.config.version); "Created pgd.toml in",
println!(" Port: {}", project.config.port); project.path.display().to_string().bright_white().bold()
println!(" Password: {}", "*".repeat(project.config.password.len())); );
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("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![
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("Port").fg(Color::White),
Cell::new(project.config.port.to_string()).add_attribute(Attribute::Bold),
]);
table.add_row(vec![
Cell::new("Password").fg(Color::White),
Cell::new("*".repeat(project.config.password.len())).fg(Color::DarkGrey),
]);
println!("{table}");
self.reconcile(&project).await?; self.reconcile(&project).await?;
println!("\nProject initialized successfully!"); println!("\n{}", "Project initialized successfully!".green().bold());
Ok(()) Ok(())
} }
@@ -97,17 +135,46 @@ impl Controller {
.is_container_running_by_id(&container_id) .is_container_running_by_id(&container_id)
.await? .await?
{ {
println!("{}", "Container is already running".white());
return Ok(()); return Ok(());
} }
println!("Starting container..."); 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 { for attempt in 1..=MAX_RETRIES {
let result = self.try_starting_container(&container_id, attempt).await; spinner.set_message(format!(
"Starting container (attempt {}/{})",
attempt, MAX_RETRIES
));
let result = self.try_starting_container(&container_id, &spinner).await;
match result { match result {
Ok(_) => return Ok(()), Ok(_) => {
Err(err) => println!("Error: {:#?}", err), 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 { if attempt < MAX_RETRIES {
@@ -115,37 +182,43 @@ impl Controller {
} }
} }
spinner.finish_with_message(format!("{}", "Failed to start container".red()));
miette::bail!("Failed to start container after {} attempts", MAX_RETRIES) miette::bail!("Failed to start container after {} attempts", MAX_RETRIES)
} }
async fn try_starting_container( async fn try_starting_container(
&self, &self,
container_id: &String, container_id: &String,
attempt: u32, spinner: &indicatif::ProgressBar,
) -> Result<(), miette::Error> { ) -> Result<(), miette::Error> {
match self.docker.start_container_by_id(container_id).await { match self.docker.start_container_by_id(container_id).await {
Ok(_) => { Ok(_) => {
tokio::time::sleep(tokio::time::Duration::from_secs(VERIFY_DURATION_SECS)).await; 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? { if self.docker.is_container_running_by_id(container_id).await? {
println!("Container started successfully and verified running");
return Ok(()); return Ok(());
} else { } else {
println!( miette::bail!("Container stopped unexpectedly after start");
"Container stopped unexpectedly after start (attempt {}/{})",
attempt, MAX_RETRIES
);
} }
} }
Err(e) => { Err(e) => {
println!( miette::bail!("Failed to start: {}", e);
"Failed to start container (attempt {}/{}): {}",
attempt, MAX_RETRIES, e
);
} }
}; }
Ok(())
} }
async fn update_project_container( async fn update_project_container(
@@ -153,7 +226,11 @@ impl Controller {
project: &Project, project: &Project,
state: &mut StateManager, state: &mut StateManager,
) -> Result<String, miette::Error> { ) -> Result<String, miette::Error> {
println!("Creating container {}...", project.container_name()); println!(
"{} {}",
"Creating container".cyan(),
project.container_name().yellow()
);
let id = self let id = self
.docker .docker
.create_postgres_container( .create_postgres_container(
@@ -163,7 +240,7 @@ impl Controller {
project.config.port, project.config.port,
) )
.await?; .await?;
println!("Container created successfully"); println!("{}", "Container created successfully".green());
state.set( state.set(
project.name.clone(), project.name.clone(),
crate::state::InstanceState::new( crate::state::InstanceState::new(

View File

@@ -9,6 +9,7 @@ use bollard::{
}, },
secret::ContainerCreateBody, secret::ContainerCreateBody,
}; };
use colored::Colorize;
use indicatif::MultiProgress; use indicatif::MultiProgress;
use miette::{Context, IntoDiagnostic, Result}; use miette::{Context, IntoDiagnostic, Result};
use tracing::info; use tracing::info;
@@ -34,7 +35,7 @@ impl DockerController {
let docker = Docker::connect_with_local_defaults() let docker = Docker::connect_with_local_defaults()
.into_diagnostic() .into_diagnostic()
.wrap_err( .wrap_err(
"Failed to connect to Docker! pgx 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"); info!("docker.created");
@@ -58,11 +59,11 @@ impl DockerController {
let multi = MultiProgress::new(); let multi = MultiProgress::new();
println!("Downloading {image}"); println!("{} {}", "Downloading".cyan(), image.yellow());
download::perform_download(multi, download_progress).await?; download::perform_download(multi, download_progress).await?;
println!("Download complete!"); println!("{}", "Download complete!".green().bold());
Ok(()) Ok(())
} }
@@ -156,7 +157,7 @@ impl DockerController {
}; };
let mut labels = HashMap::new(); let mut labels = HashMap::new();
labels.insert("pgx.postgres.version".to_string(), version.to_string()); labels.insert("pgd.postgres.version".to_string(), version.to_string());
let config = ContainerCreateBody { let config = ContainerCreateBody {
image: Some(image), image: Some(image),
@@ -255,8 +256,8 @@ impl DockerController {
.ok_or_else(|| miette!("Container has no labels"))?; .ok_or_else(|| miette!("Container has no labels"))?;
let version_str = labels let version_str = labels
.get("pgx.postgres.version") .get("pgd.postgres.version")
.ok_or_else(|| miette!("Container missing pgx.postgres.version label"))?; .ok_or_else(|| miette!("Container missing pgd.postgres.version label"))?;
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))

View File

@@ -23,13 +23,12 @@ async fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
init_tracing(cli.verbose); init_tracing(cli.verbose);
info!("pgx.start"); info!("pgd.start");
let controller = Controller::new().await?; let controller = Controller::new().await?;
match cli.command { match cli.command {
cli::Commands::Init => controller.init_project().await?, cli::Commands::Init => controller.init_project().await?,
cli::Commands::Instance { name, cmd } => todo!(), cli::Commands::Instance { name, cmd } => todo!(),
cli::Commands::Sync => todo!(),
} }
Ok(()) Ok(())

View File

@@ -21,7 +21,7 @@ pub struct InstanceState {
pub created_at: u64, pub created_at: u64,
} }
/// Manages the global state file at ~/.pgx/state.json /// Manages the global state file at ~/.pgd/state.json
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateManager { pub struct StateManager {
/// Map of project name to instance state /// Map of project name to instance state
@@ -29,24 +29,16 @@ pub struct StateManager {
instances: HashMap<String, InstanceState>, instances: HashMap<String, InstanceState>,
} }
/// Get the path to the state file (~/.pgx/state.json) /// Get the path to the state file (~/.pgd/state.json)
fn state_file_path() -> Result<PathBuf> { fn state_file_path() -> Result<PathBuf> {
let home = std::env::var("HOME") let home = std::env::var("HOME")
.into_diagnostic() .into_diagnostic()
.wrap_err("Failed to get HOME environment variable")?; .wrap_err("Failed to get HOME environment variable")?;
Ok(PathBuf::from(home).join(".pgx").join("state.json")) Ok(PathBuf::from(home).join(".pgd").join("state.json"))
} }
/// Get the path to the .pgx directory
pub fn pgx_dir() -> Result<PathBuf> {
let home = std::env::var("HOME")
.into_diagnostic()
.wrap_err("Failed to get HOME environment variable")?;
Ok(PathBuf::from(home).join(".pgx"))
}
impl StateManager { impl StateManager {
/// Load the state manager from disk, or create a new one if it doesn't exist /// Load the state manager from disk, or create a new one if it doesn't exist
@@ -58,7 +50,7 @@ impl StateManager {
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 .pgx directory")?; .wrap_err("Failed to create .pgd directory")?;
} }
// Return empty state // Return empty state
@@ -86,7 +78,7 @@ impl StateManager {
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 .pgx directory")?; .wrap_err("Failed to create .pgd directory")?;
} }
let content = serde_json::to_string_pretty(self) let content = serde_json::to_string_pretty(self)
@@ -100,11 +92,6 @@ impl StateManager {
Ok(()) Ok(())
} }
/// Get the state for a specific project
pub fn get(&self, project_name: &str) -> Option<&InstanceState> {
self.instances.get(project_name)
}
/// Get mutable state for a specific project /// Get mutable state for a specific project
pub fn get_mut(&mut self, project_name: &str) -> Option<&mut InstanceState> { pub fn get_mut(&mut self, project_name: &str) -> Option<&mut InstanceState> {
self.instances.get_mut(project_name) self.instances.get_mut(project_name)
@@ -115,28 +102,10 @@ impl StateManager {
self.instances.insert(project_name, state); self.instances.insert(project_name, state);
} }
/// Update the state for a specific project, creating it if it doesn't exist
pub fn update<F>(&mut self, project_name: &str, updater: F) -> Result<()>
where
F: FnOnce(&mut InstanceState),
{
if let Some(state) = self.instances.get_mut(project_name) {
updater(state);
Ok(())
} else {
miette::bail!("No state found for project: {}", project_name)
}
}
/// Remove the state for a specific project /// Remove the state for a specific project
pub fn remove(&mut self, project_name: &str) -> Option<InstanceState> { pub fn remove(&mut self, project_name: &str) -> Option<InstanceState> {
self.instances.remove(project_name) self.instances.remove(project_name)
} }
/// Get all instances
pub fn all_instances(&self) -> &HashMap<String, InstanceState> {
&self.instances
}
} }
impl InstanceState { impl InstanceState {
@@ -153,36 +122,4 @@ impl InstanceState {
created_at: now, created_at: now,
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_state_manager_operations() {
let mut manager = StateManager {
instances: HashMap::new(),
};
let state = InstanceState::new(
"container123".to_string(),
PostgresVersion {
major: 18,
minor: 1,
},
5432,
);
manager.set("my-project".to_string(), state);
assert!(manager.get("my-project").is_some());
assert_eq!(
manager.get("my-project").unwrap().container_id,
"container123"
);
manager.remove("my-project");
assert!(manager.get("my-project").is_none());
}
}