Compare commits

..

21 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
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
19 changed files with 1385 additions and 383 deletions

1
.gitignore vendored
View File

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

View File

@@ -4,27 +4,27 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## 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
### Project-Oriented Design
- Each project has a `pgx.toml` file at its root that defines the Postgres configuration
- The project name is derived from the directory containing `pgx.toml`
- Each project has a `pgd.toml` file at its root that defines the Postgres configuration
- The project name is derived from the directory containing `pgd.toml`
- Each project gets its own Docker container
- State is tracked separately per instance to detect configuration drift
### Configuration Management
The `pgx.toml` file stores:
The `pgd.toml` file stores:
- `postgres_version`: PostgreSQL version to use
- `database_name`: Name of the database
- `user_name`: Database user
- `password`: Database password
- `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
@@ -36,7 +36,7 @@ The tool maintains separate state for each instance to detect configuration drif
## Key Dependencies
- **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
- **tokio** (with full feature set): Async runtime for Docker operations
- **tracing** + **tracing-subscriber**: Structured logging throughout the application
@@ -80,15 +80,15 @@ cargo fmt -- --check # Check without modifying
The CLI follows this pattern:
```
pgx <command> [options]
pgd <command> [options]
```
Key commands to implement:
- `pgx init`: Create pgx.toml with auto-populated configuration
- `pgx start`: Start the Postgres container for current project
- `pgx stop`: Stop the running container
- `pgx status`: Show instance status and detect drift
- `pgx destroy`: Remove container and clean up
- `pgd init`: Create pgd.toml with auto-populated configuration
- `pgd start`: Start the Postgres container for current project
- `pgd stop`: Stop the running container
- `pgd status`: Show instance status and detect drift
- `pgd destroy`: Remove container and clean up
## Implementation Notes

226
Cargo.lock generated
View File

@@ -219,6 +219,17 @@ dependencies = [
"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]]
name = "clap_builder"
version = "4.5.53"
@@ -249,12 +260,59 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "colorchoice"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
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]]
name = "console"
version = "0.16.1"
@@ -274,6 +332,29 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "darling"
version = "0.21.3"
@@ -330,6 +411,15 @@ dependencies = [
"syn",
]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]]
name = "dyn-clone"
version = "1.0.20"
@@ -789,7 +879,7 @@ version = "0.18.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9375e112e4b463ec1b1c6c011953545c65a30164fbab5b581df32b3abf0dcb88"
dependencies = [
"console",
"console 0.16.1",
"portable-atomic",
"unicode-segmentation",
"unicode-width 0.2.2",
@@ -849,6 +939,12 @@ version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -1001,18 +1097,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pgx"
version = "0.1.0"
name = "pgd"
version = "0.0.2"
dependencies = [
"bollard",
"clap",
"clap-verbosity-flag",
"cliclack",
"colored",
"comfy-table",
"futures",
"indicatif",
"miette",
"parking_lot",
"rand",
"serde",
"serde_json",
"serde_with",
"thiserror",
"tokio",
"toml",
"tracing",
@@ -1346,6 +1448,12 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "smawk"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c"
[[package]]
name = "socket2"
version = "0.6.1"
@@ -1427,6 +1535,7 @@ version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
dependencies = [
"smawk",
"unicode-linebreak",
"unicode-width 0.2.2",
]
@@ -1876,13 +1985,22 @@ dependencies = [
"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]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets",
"windows-targets 0.53.5",
]
[[package]]
@@ -1894,6 +2012,22 @@ dependencies = [
"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]]
name = "windows-targets"
version = "0.53.5"
@@ -1901,58 +2035,106 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link",
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"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]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "windows_x86_64_msvc"
version = "0.53.1"
@@ -2041,6 +2223,26 @@ dependencies = [
"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]]
name = "zerotrie"
version = "0.2.3"

View File

@@ -1,18 +1,27 @@
[package]
name = "pgx"
version = "0.1.0"
name = "pgd"
version = "0.0.2"
edition = "2024"
description = "CLI to manage postgres instances for local development"
repository = "https://github.com/MarketTakers/pgd"
license = "MIT"
[dependencies]
bollard = "0.19.4"
clap = { version = "4.5.53", features = ["derive"] }
clap-verbosity-flag = { version = "3.0.4", features = ["tracing"] }
cliclack = "0.3.7"
colored = "3.0.0"
comfy-table = "7.2.1"
futures = "0.3.31"
indicatif = { version = "0.18.3", features = ["improved_unicode"] }
miette = { version = "7.6.0", features = ["fancy"] }
parking_lot = "0.12.5"
rand = "0.9.2"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
serde_with = "3.16.1"
thiserror = "2.0.17"
tokio = { version = "1.48.0", features = ["full"] }
toml = "0.9.8"
tracing = "0.1.43"

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.

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 = "P8t42UQ53iIza2Ic"
port = 5432

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

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

View File

@@ -7,26 +7,24 @@ const STYLES: styling::Styles = styling::Styles::styled()
.placeholder(styling::AnsiColor::Cyan.on_default());
#[derive(Parser)]
#[command(name = "pgx")]
#[command(about = "Project-scoped PostgreSQL instance manager", long_about = None)]
#[command(name = "pgd")]
#[command(about = "Project-scoped PostgreSQL instance manager", long_about = include_str!("./banner.txt"))]
#[command(version)]
#[command(styles = STYLES)]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
#[arg(short, long, global = true)]
pub verbose: bool,
#[command(flatten)]
pub verbosity: clap_verbosity_flag::Verbosity,
}
#[derive(Clone, clap::ValueEnum)]
pub enum ConnectionFormat {
/// Human-readable text format
Text,
/// JSON format
Json,
/// Environment variable format
Env,
/// DSN Url
Dsn,
// Human readable format
Human,
}
#[derive(Subcommand)]
@@ -38,21 +36,28 @@ pub enum ControlCommands {
/// Restart postgres instance
Restart,
/// (WARNING!) Destroy postgres instance
Destroy,
Destroy { force: bool },
/// (WARNING!) Destruct database
Wipe { force: bool },
/// Status of instance
Status,
/// View logs produced by postgres
Logs { follow: bool },
Logs {
#[arg(short, long, default_value = "false")]
follow: bool,
},
/// (Sensitive) get connection details
Connection { format: ConnectionFormat },
Conn {
#[arg(short, long, default_value = "dsn")]
format: ConnectionFormat,
},
}
#[derive(Subcommand)]
pub enum Commands {
/// Create a new project, or initialize instance for existing one
Init,
/// Create a new project, or initialize instance for existing one
Sync,
/// Start the PostgreSQL container for the current project
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]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PgxConfig {
pub struct PGDConfig {
/// PostgreSQL version to use
#[serde_as(as = "DisplayFromStr")]
pub version: PostgresVersion,
@@ -52,16 +52,16 @@ pub struct PgxConfig {
pub port: u16,
}
impl PgxConfig {
impl PGDConfig {
pub fn load(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
let content = std::fs::read_to_string(path)
.into_diagnostic()
.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()
.wrap_err("Failed to parse pgx.toml")?;
.wrap_err("Failed to parse pgd.toml")?;
Ok(config)
}
@@ -85,16 +85,16 @@ pub struct Project {
/// Project name (derived from directory name)
pub name: String,
/// Path to the project directory containing pgx.toml
/// Path to the project directory containing pgd.toml
pub path: PathBuf,
pub config: PgxConfig,
pub config: PGDConfig,
}
impl Project {
pub fn container_name(&self) -> String {
let container_name = format!(
"pgx-{}-{}",
"pgd-{}-{}",
self.name,
self.config.version.to_string().replace('.', "_")
);
@@ -110,7 +110,7 @@ impl Project {
return Ok(None);
}
let config = PgxConfig::load(&config_path)?;
let config = PGDConfig::load(&config_path)?;
let name = Self::extract_project_name(&project_path)?;
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 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"))
}
/// Get the path to the pgx.toml file
/// Get the path to the pgd.toml file
pub fn config_path(&self) -> PathBuf {
self.path.join("pgx.toml")
self.path.join("pgd.toml")
}
/// Save the current configuration

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,232 +1,501 @@
use miette::{bail, miette};
use miette::miette;
use miette::Result;
use colored::Colorize;
use comfy_table::{Attribute, Cell, Color, ContentArrangement, Table, presets::UTF8_FULL};
use futures::TryStreamExt;
use miette::{IntoDiagnostic, Result};
use crate::{
config::{PgxConfig, PostgresVersion, Project},
controller::docker::DockerController,
cli::ConnectionFormat,
config::{PGDConfig, Project},
consts::{DATABASE, USERNAME},
controller::{docker::DockerController, reconciler::Reconciler},
state::{InstanceState, StateManager},
};
mod docker;
mod utils;
const MAX_RETRIES: u32 = 10;
const VERIFY_DURATION_SECS: u64 = 10;
pub mod reconciler;
pub struct Controller {
pub struct Context {
docker: DockerController,
project: Option<Project>,
instance: Option<InstanceState>,
state: StateManager,
}
impl Controller {
pub async fn new() -> Result<Self> {
impl Context {
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 {
docker: DockerController::new().await?,
project: Project::load()?,
state: StateManager::load()?,
project,
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<()> {
if let Some(project) = &self.project {
return self.reconcile(project).await;
let reconciler = Reconciler { ctx: &self.ctx };
if let Some(project) = &self.ctx.project {
return reconciler.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.ctx.docker.available_versions().await?;
versions.sort();
let latest_version = versions
.last()
.ok_or(miette!("expected to have at least one version"))?;
let config = PgxConfig {
let config = PGDConfig {
version: *latest_version,
password: utils::generate_password(),
port: utils::find_available_port()?,
port: utils::find_available_port(&self.ctx.state)?,
};
let project = Project::new(config)?;
println!("Created pgx.toml in {}", project.path.display());
println!(" Project: {}", project.name);
println!(" PostgreSQL version: {}", project.config.version);
println!(" Port: {}", project.config.port);
println!(" Password: {}", "*".repeat(project.config.password.len()));
self.reconcile(&project).await?;
println!("\nProject initialized successfully!");
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?
{
return Ok(());
}
println!("Starting container...");
for attempt in 1..=MAX_RETRIES {
let result = self.try_starting_container(&container_id, attempt).await;
match result {
Ok(_) => return Ok(()),
Err(err) => println!("Error: {:#?}", err),
}
if attempt < MAX_RETRIES {
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
}
}
miette::bail!("Failed to start container after {} attempts", MAX_RETRIES)
}
async fn try_starting_container(
&self,
container_id: &String,
attempt: u32,
) -> Result<(), miette::Error> {
match self.docker.start_container_by_id(container_id).await {
Ok(_) => {
tokio::time::sleep(tokio::time::Duration::from_secs(VERIFY_DURATION_SECS)).await;
if self.docker.is_container_running_by_id(container_id).await? {
println!("Container started successfully and verified running");
return Ok(());
} else {
println!(
"Container stopped unexpectedly after start (attempt {}/{})",
attempt, MAX_RETRIES
"\nCreated pgd.toml in {}\n",
project.path.display().to_string().bright_white().bold()
);
}
}
Err(e) => {
let mut table = create_ui_table("Project Configuration".to_string());
table.add_row(vec![
Cell::new("Project").fg(Color::White),
Cell::new(&project.name).add_attribute(Attribute::Bold),
]);
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}");
reconciler.reconcile(&project).await?;
println!(
"Failed to start container (attempt {}/{}): {}",
attempt, MAX_RETRIES, e
"\n{} {}",
"".green().bold(),
"Project initialized successfully!".green(),
);
}
};
Ok(())
}
async fn update_project_container(
&self,
project: &Project,
state: &mut StateManager,
) -> Result<String, miette::Error> {
println!("Creating container {}...", project.container_name());
let id = self
.docker
.create_postgres_container(
&project.container_name(),
&project.config.version,
&project.config.password,
project.config.port,
)
.await?;
println!("Container created successfully");
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(())
}
}
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,17 +1,21 @@
use miette::miette;
use miette::{Diagnostic, miette};
use std::str::FromStr;
use thiserror::Error;
use bollard::{
Docker,
container::LogOutput,
query_parameters::{
CreateContainerOptions, CreateImageOptions, InspectContainerOptions, ListImagesOptions,
StartContainerOptions, StopContainerOptions,
LogsOptions, StartContainerOptions, StopContainerOptions,
},
secret::ContainerCreateBody,
};
use colored::Colorize;
use futures::{Stream, StreamExt};
use indicatif::MultiProgress;
use miette::{Context, IntoDiagnostic, Result};
use tracing::info;
use tracing::{debug, info};
use crate::{
config::PostgresVersion,
@@ -25,6 +29,11 @@ fn format_image(ver: &PostgresVersion) -> String {
format!("{DOCKERHUB_POSTGRES}:{}", ver)
}
#[derive(Error, Debug, Diagnostic)]
#[error("Docker operation failed")]
#[diagnostic(code(pgd::docker))]
pub enum Error {}
pub struct DockerController {
daemon: Docker,
}
@@ -34,10 +43,10 @@ impl DockerController {
let docker = Docker::connect_with_local_defaults()
.into_diagnostic()
.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");
debug!("Connected to docker!");
docker
.list_images(Some(ListImagesOptions::default()))
@@ -58,11 +67,11 @@ impl DockerController {
let multi = MultiProgress::new();
println!("Downloading {image}");
println!("{} {}", "Downloading".cyan(), image.yellow());
download::perform_download(multi, download_progress).await?;
println!("Download complete!");
println!("{}", "Download complete!".green().bold());
Ok(())
}
@@ -156,7 +165,7 @@ impl DockerController {
};
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 {
image: Some(image),
@@ -255,10 +264,108 @@ impl DockerController {
.ok_or_else(|| miette!("Container has no labels"))?;
let version_str = labels
.get("pgx.postgres.version")
.ok_or_else(|| miette!("Container missing pgx.postgres.version label"))?;
.get("pgd.postgres.version")
.ok_or_else(|| miette!("Container missing pgd.postgres.version label"))?;
PostgresVersion::from_str(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 {
let pb = multi.add(ProgressBar::new(0));
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()
.with_key("eta", |state: &ProgressState, w: &mut dyn Write| {
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 rand::{Rng, distr::Alphanumeric};
use crate::state::StateManager;
const DEFAULT_POSTGRES_PORT: u16 = 5432;
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;
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() {
return Ok(port);
}

View File

@@ -2,41 +2,59 @@ mod cli;
mod config;
mod state;
mod consts {
pub const USERNAME: &str = "postgres";
pub const DATABASE: &str = "postgres";
}
mod consts;
mod controller;
use clap::Parser;
use clap_verbosity_flag::Verbosity;
use cli::Cli;
use miette::Result;
use tracing::info;
use tracing::debug;
use crate::controller::Controller;
use crate::{
cli::ControlCommands,
controller::{Context, Controller},
};
#[tokio::main]
async fn main() -> Result<()> {
println!("{}", include_str!("./banner.txt"));
let cli = Cli::parse();
init_tracing(cli.verbose);
init_tracing(cli.verbosity);
info!("pgx.start");
let controller = Controller::new().await?;
debug!("pgd.start");
macro_rules! do_cmd {
($name:expr, $method:ident $(, $arg:expr)*) => {{
let ctx = Context::new($name).await?;
Controller::new(ctx).$method($($arg),*).await?;
}};
}
match cli.command {
cli::Commands::Init => controller.init_project().await?,
cli::Commands::Instance { name, cmd } => todo!(),
cli::Commands::Sync => todo!(),
cli::Commands::Init => {
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(())
}
fn init_tracing(verbose: bool) {
tracing_subscriber::fmt::init();
fn init_tracing(verbosity: Verbosity) {
tracing_subscriber::fmt()
.with_max_level(verbosity)
.without_time()
.with_target(false)
.init();
}

View File

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