Compare commits
21 Commits
d39f67f3fe
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f89832d736 | |||
| 5d215fff8e | |||
| 4c64e411f9 | |||
| 6d16748cb3 | |||
|
|
a01f511311 | ||
|
|
be866cfb1a | ||
|
|
ef7dc8ba07 | ||
|
|
471f9129b4 | ||
|
|
570cec8b47 | ||
|
|
04797a5fb9 | ||
|
|
eee5f4c714 | ||
|
|
5b8feaa3c8 | ||
|
|
9de82fb71a | ||
|
|
c6929255e3 | ||
|
|
676c53fabb | ||
|
|
c45e9305e5 | ||
|
|
bc37b58d80 | ||
|
|
bf0ae1b3ee | ||
|
|
ddb28085b9 | ||
|
|
71f363fde9 | ||
|
|
6e2ee650d8 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
/target
|
/target
|
||||||
|
pgd.toml
|
||||||
|
|||||||
24
CLAUDE.md
24
CLAUDE.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
226
Cargo.lock
generated
226
Cargo.lock
generated
@@ -219,6 +219,17 @@ dependencies = [
|
|||||||
"clap_derive",
|
"clap_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap-verbosity-flag"
|
||||||
|
version = "3.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9d92b1fab272fe943881b77cc6e920d6543e5b1bfadbd5ed81c7c5a755742394"
|
||||||
|
dependencies = [
|
||||||
|
"clap",
|
||||||
|
"log",
|
||||||
|
"tracing-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_builder"
|
name = "clap_builder"
|
||||||
version = "4.5.53"
|
version = "4.5.53"
|
||||||
@@ -249,12 +260,59 @@ version = "0.7.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
|
checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cliclack"
|
||||||
|
version = "0.3.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2381872509dfa50d8b92b92a5da8367ba68458ab9494be4134b57ad6ca26295f"
|
||||||
|
dependencies = [
|
||||||
|
"console 0.15.11",
|
||||||
|
"indicatif",
|
||||||
|
"once_cell",
|
||||||
|
"strsim",
|
||||||
|
"textwrap",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colorchoice"
|
name = "colorchoice"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
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]]
|
||||||
|
name = "console"
|
||||||
|
version = "0.15.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
|
||||||
|
dependencies = [
|
||||||
|
"encode_unicode",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"unicode-width 0.2.2",
|
||||||
|
"windows-sys 0.59.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "console"
|
name = "console"
|
||||||
version = "0.16.1"
|
version = "0.16.1"
|
||||||
@@ -274,6 +332,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 +411,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"
|
||||||
@@ -789,7 +879,7 @@ version = "0.18.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9375e112e4b463ec1b1c6c011953545c65a30164fbab5b581df32b3abf0dcb88"
|
checksum = "9375e112e4b463ec1b1c6c011953545c65a30164fbab5b581df32b3abf0dcb88"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"console",
|
"console 0.16.1",
|
||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"unicode-width 0.2.2",
|
"unicode-width 0.2.2",
|
||||||
@@ -849,6 +939,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,18 +1097,24 @@ 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.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bollard",
|
"bollard",
|
||||||
"clap",
|
"clap",
|
||||||
|
"clap-verbosity-flag",
|
||||||
|
"cliclack",
|
||||||
|
"colored",
|
||||||
|
"comfy-table",
|
||||||
"futures",
|
"futures",
|
||||||
"indicatif",
|
"indicatif",
|
||||||
"miette",
|
"miette",
|
||||||
|
"parking_lot",
|
||||||
"rand",
|
"rand",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_with",
|
"serde_with",
|
||||||
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
"toml",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -1346,6 +1448,12 @@ version = "1.15.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "smawk"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "socket2"
|
name = "socket2"
|
||||||
version = "0.6.1"
|
version = "0.6.1"
|
||||||
@@ -1427,6 +1535,7 @@ version = "0.16.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
|
checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"smawk",
|
||||||
"unicode-linebreak",
|
"unicode-linebreak",
|
||||||
"unicode-width 0.2.2",
|
"unicode-width 0.2.2",
|
||||||
]
|
]
|
||||||
@@ -1876,13 +1985,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 +2012,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 +2035,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"
|
||||||
@@ -2041,6 +2223,26 @@ dependencies = [
|
|||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize"
|
||||||
|
version = "1.8.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||||
|
dependencies = [
|
||||||
|
"zeroize_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize_derive"
|
||||||
|
version = "1.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
|
|||||||
13
Cargo.toml
13
Cargo.toml
@@ -1,18 +1,27 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "pgx"
|
name = "pgd"
|
||||||
version = "0.1.0"
|
version = "0.0.2"
|
||||||
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"] }
|
||||||
|
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"
|
futures = "0.3.31"
|
||||||
indicatif = { version = "0.18.3", features = ["improved_unicode"] }
|
indicatif = { version = "0.18.3", features = ["improved_unicode"] }
|
||||||
miette = { version = "7.6.0", features = ["fancy"] }
|
miette = { version = "7.6.0", features = ["fancy"] }
|
||||||
|
parking_lot = "0.12.5"
|
||||||
rand = "0.9.2"
|
rand = "0.9.2"
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0.145"
|
serde_json = "1.0.145"
|
||||||
serde_with = "3.16.1"
|
serde_with = "3.16.1"
|
||||||
|
thiserror = "2.0.17"
|
||||||
tokio = { version = "1.48.0", features = ["full"] }
|
tokio = { version = "1.48.0", features = ["full"] }
|
||||||
toml = "0.9.8"
|
toml = "0.9.8"
|
||||||
tracing = "0.1.43"
|
tracing = "0.1.43"
|
||||||
|
|||||||
21
LICENSE.md
Normal file
21
LICENSE.md
Normal 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
159
README.md
Normal 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.
|
||||||
30
release.toml
Normal file
30
release.toml
Normal 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
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
_______ _______ __ __
|
▄▄▄▄ ▐▌
|
||||||
| || || |_| |
|
█ █ ▐▌
|
||||||
| _ || ___|| |
|
█▄▄▄▀ ▗▞▀▜▌
|
||||||
| |_| || | __ | |
|
█ ▗▄▖▝▚▄▟▌
|
||||||
| ___|| || | | |
|
▀ ▐▌ ▐▌
|
||||||
| | | |_| || _ |
|
▝▀▜▌
|
||||||
|___| |_______||__| |__|
|
▐▙▄▞▘
|
||||||
|
|
||||||
|
Project-scoped PostgreSQL instance manager
|
||||||
|
|||||||
35
src/cli.rs
35
src/cli.rs
@@ -7,26 +7,24 @@ 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 = include_str!("./banner.txt"))]
|
||||||
#[command(version)]
|
#[command(version)]
|
||||||
#[command(styles = STYLES)]
|
#[command(styles = STYLES)]
|
||||||
pub struct Cli {
|
pub struct Cli {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
pub command: Commands,
|
pub command: Commands,
|
||||||
|
|
||||||
#[arg(short, long, global = true)]
|
#[command(flatten)]
|
||||||
pub verbose: bool,
|
pub verbosity: clap_verbosity_flag::Verbosity,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, clap::ValueEnum)]
|
#[derive(Clone, clap::ValueEnum)]
|
||||||
pub enum ConnectionFormat {
|
pub enum ConnectionFormat {
|
||||||
/// Human-readable text format
|
/// DSN Url
|
||||||
Text,
|
Dsn,
|
||||||
/// JSON format
|
// Human readable format
|
||||||
Json,
|
Human,
|
||||||
/// Environment variable format
|
|
||||||
Env,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
@@ -38,21 +36,28 @@ pub enum ControlCommands {
|
|||||||
/// Restart postgres instance
|
/// Restart postgres instance
|
||||||
Restart,
|
Restart,
|
||||||
/// (WARNING!) Destroy postgres instance
|
/// (WARNING!) Destroy postgres instance
|
||||||
Destroy,
|
Destroy { force: bool },
|
||||||
|
/// (WARNING!) Destruct database
|
||||||
|
Wipe { force: bool },
|
||||||
|
|
||||||
/// Status of instance
|
/// Status of instance
|
||||||
Status,
|
Status,
|
||||||
/// View logs produced by postgres
|
/// View logs produced by postgres
|
||||||
Logs { follow: bool },
|
Logs {
|
||||||
|
#[arg(short, long, default_value = "false")]
|
||||||
|
follow: bool,
|
||||||
|
},
|
||||||
/// (Sensitive) get connection details
|
/// (Sensitive) get connection details
|
||||||
Connection { format: ConnectionFormat },
|
Conn {
|
||||||
|
#[arg(short, long, default_value = "dsn")]
|
||||||
|
format: ConnectionFormat,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
2
src/consts.rs
Normal file
2
src/consts.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub const USERNAME: &str = "postgres";
|
||||||
|
pub const DATABASE: &str = "postgres";
|
||||||
@@ -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::{
|
use crate::{
|
||||||
config::{PgxConfig, PostgresVersion, Project},
|
cli::ConnectionFormat,
|
||||||
controller::docker::DockerController,
|
config::{PGDConfig, Project},
|
||||||
|
consts::{DATABASE, USERNAME},
|
||||||
|
controller::{docker::DockerController, reconciler::Reconciler},
|
||||||
state::{InstanceState, StateManager},
|
state::{InstanceState, StateManager},
|
||||||
};
|
};
|
||||||
|
|
||||||
mod docker;
|
mod docker;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
const MAX_RETRIES: u32 = 10;
|
pub mod reconciler;
|
||||||
const VERIFY_DURATION_SECS: u64 = 10;
|
|
||||||
|
|
||||||
pub struct Controller {
|
pub struct Context {
|
||||||
docker: DockerController,
|
docker: DockerController,
|
||||||
project: Option<Project>,
|
project: Option<Project>,
|
||||||
|
instance: Option<InstanceState>,
|
||||||
state: StateManager,
|
state: StateManager,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Controller {
|
impl Context {
|
||||||
pub async fn new() -> Result<Self> {
|
pub fn require_instance(&self) -> Result<&InstanceState> {
|
||||||
|
self.instance.as_ref().ok_or(miette!("This command requires instance. Either initiliaze a project, or pass -I with instance name"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn require_project(&self) -> Result<&Project> {
|
||||||
|
self.project.as_ref().ok_or(miette!(
|
||||||
|
"This command requires project. Please, initiliaze a project."
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn new(instance_override: Option<String>) -> Result<Self> {
|
||||||
|
let project = Project::load()?;
|
||||||
|
let state = StateManager::new()?;
|
||||||
|
|
||||||
|
let instance = match (project.as_ref(), instance_override) {
|
||||||
|
(None, None) => None,
|
||||||
|
// prioritizing provided instance name
|
||||||
|
(_, Some(instance)) => state.get(&instance),
|
||||||
|
(Some(project), None) => state.get(&project.name),
|
||||||
|
};
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
docker: DockerController::new().await?,
|
docker: DockerController::new().await?,
|
||||||
project: Project::load()?,
|
project,
|
||||||
state: StateManager::load()?,
|
instance,
|
||||||
|
state,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Main CLI command dispatcher
|
||||||
|
pub struct Controller {
|
||||||
|
ctx: Context,
|
||||||
|
}
|
||||||
|
impl Controller {
|
||||||
|
pub fn new(ctx: Context) -> Self {
|
||||||
|
Self { ctx }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn logs(&self, follow: bool) -> Result<()> {
|
||||||
|
let instance = self.ctx.require_instance()?;
|
||||||
|
|
||||||
|
let mut logs = self
|
||||||
|
.ctx
|
||||||
|
.docker
|
||||||
|
.stream_logs(&instance.container_id, follow)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
while let Some(log) = logs.try_next().await? {
|
||||||
|
let bytes = log.into_bytes();
|
||||||
|
let line = String::from_utf8_lossy(bytes.as_ref());
|
||||||
|
print!("{line}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn show_connection(&self, format: ConnectionFormat) -> Result<()> {
|
||||||
|
let project = self.ctx.require_project()?;
|
||||||
|
let reconciler = Reconciler { ctx: &self.ctx };
|
||||||
|
|
||||||
|
reconciler.reconcile(project).await?;
|
||||||
|
|
||||||
|
match format {
|
||||||
|
ConnectionFormat::Dsn => {
|
||||||
|
println!(
|
||||||
|
"postgres://{}:{}@127.0.0.1:{}/{}",
|
||||||
|
USERNAME, project.config.password, project.config.port, DATABASE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ConnectionFormat::Human => {
|
||||||
|
format_conn_human(project);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start(&self) -> Result<()> {
|
||||||
|
let project = self.ctx.require_project()?;
|
||||||
|
let reconciler = Reconciler { ctx: &self.ctx };
|
||||||
|
|
||||||
|
println!("{}", "Starting container...".cyan());
|
||||||
|
reconciler.reconcile(project).await?;
|
||||||
|
println!(
|
||||||
|
"{} {} {}",
|
||||||
|
"✓".green().bold(),
|
||||||
|
"Container started".green(),
|
||||||
|
project.container_name().yellow()
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn stop(&self) -> Result<()> {
|
||||||
|
let instance = self.ctx.require_instance()?;
|
||||||
|
let project = self.ctx.require_project()?;
|
||||||
|
|
||||||
|
if !self
|
||||||
|
.ctx
|
||||||
|
.docker
|
||||||
|
.is_container_running_by_id(&instance.container_id)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
println!("{}", "Container is not running".yellow());
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("{}", "Stopping container...".cyan());
|
||||||
|
self.ctx
|
||||||
|
.docker
|
||||||
|
.stop_container(&instance.container_id, 10)
|
||||||
|
.await?;
|
||||||
|
println!(
|
||||||
|
"{} {} {}",
|
||||||
|
"✓".green().bold(),
|
||||||
|
"Stopped container".green(),
|
||||||
|
project.container_name().yellow()
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn restart(&self) -> Result<()> {
|
||||||
|
let instance = self.ctx.require_instance()?;
|
||||||
|
let project = self.ctx.require_project()?;
|
||||||
|
let reconciler = Reconciler { ctx: &self.ctx };
|
||||||
|
|
||||||
|
println!("{}", "Restarting container...".cyan());
|
||||||
|
|
||||||
|
// Stop container first if it's running, otherwise reconciler won't do anything
|
||||||
|
if self
|
||||||
|
.ctx
|
||||||
|
.docker
|
||||||
|
.is_container_running_by_id(&instance.container_id)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
self.ctx
|
||||||
|
.docker
|
||||||
|
.stop_container(&instance.container_id, 10)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
reconciler.reconcile(project).await?;
|
||||||
|
println!(
|
||||||
|
"{} {} {}",
|
||||||
|
"✓".green().bold(),
|
||||||
|
"Restarted container".green(),
|
||||||
|
project.container_name().yellow()
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn destroy(&self, force: bool) -> Result<()> {
|
||||||
|
let instance = self.ctx.require_instance()?;
|
||||||
|
let project = self.ctx.require_project()?;
|
||||||
|
|
||||||
|
if !force {
|
||||||
|
use cliclack::{confirm, outro};
|
||||||
|
let confirmed = confirm(
|
||||||
|
format!(
|
||||||
|
"Are you sure you want to destroy container '{}'? This will remove the container and all its volumes.",
|
||||||
|
project.container_name()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.interact()
|
||||||
|
.into_diagnostic()?;
|
||||||
|
|
||||||
|
if !confirmed {
|
||||||
|
outro("Operation cancelled".to_string()).into_diagnostic()?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("{}", "Destroying container...".cyan());
|
||||||
|
|
||||||
|
// Stop if running
|
||||||
|
if self
|
||||||
|
.ctx
|
||||||
|
.docker
|
||||||
|
.is_container_running_by_id(&instance.container_id)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
self.ctx
|
||||||
|
.docker
|
||||||
|
.stop_container(&instance.container_id, 5)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove container
|
||||||
|
self.ctx
|
||||||
|
.docker
|
||||||
|
.remove_container(&instance.container_id, true)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Remove from state
|
||||||
|
self.ctx.state.remove(&project.name);
|
||||||
|
self.ctx.state.save()?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{} {} {}",
|
||||||
|
"✓".green().bold(),
|
||||||
|
"Destroyed container".green(),
|
||||||
|
project.container_name().yellow()
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn wipe(&self, force: bool) -> Result<()> {
|
||||||
|
let instance = self.ctx.require_instance()?;
|
||||||
|
let project = self.ctx.require_project()?;
|
||||||
|
|
||||||
|
if !force {
|
||||||
|
use cliclack::{confirm, outro};
|
||||||
|
let confirmed = confirm(
|
||||||
|
"Are you sure you want to wipe all database data? This action cannot be undone."
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.interact()
|
||||||
|
.into_diagnostic()?;
|
||||||
|
|
||||||
|
if !confirmed {
|
||||||
|
outro("Operation cancelled".to_string()).into_diagnostic()?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_running = self
|
||||||
|
.ctx
|
||||||
|
.docker
|
||||||
|
.is_container_running_by_id(&instance.container_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !is_running {
|
||||||
|
println!("{}", "Starting container to wipe data...".cyan());
|
||||||
|
self.ctx
|
||||||
|
.docker
|
||||||
|
.start_container_by_id(&instance.container_id)
|
||||||
|
.await?;
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("{}", "Wiping database...".cyan());
|
||||||
|
|
||||||
|
// Drop and recreate database
|
||||||
|
let drop_query = format!("DROP DATABASE IF EXISTS {};", DATABASE);
|
||||||
|
let drop_cmd = vec!["psql", "-U", USERNAME, "-c", &drop_query];
|
||||||
|
self.ctx
|
||||||
|
.docker
|
||||||
|
.exec_in_container(&instance.container_id, drop_cmd)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let create_query = format!("CREATE DATABASE {};", DATABASE);
|
||||||
|
let create_cmd = vec!["psql", "-U", USERNAME, "-c", &create_query];
|
||||||
|
self.ctx
|
||||||
|
.docker
|
||||||
|
.exec_in_container(&instance.container_id, create_cmd)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{} {} {}",
|
||||||
|
"✓".green().bold(),
|
||||||
|
"Wiped database for".green(),
|
||||||
|
project.name.yellow()
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn status(&self) -> Result<()> {
|
||||||
|
let project = self.ctx.require_project()?;
|
||||||
|
|
||||||
|
let mut table = create_ui_table(format!("Status: {}", project.name));
|
||||||
|
|
||||||
|
table.add_row(vec![
|
||||||
|
Cell::new("Project").fg(Color::White),
|
||||||
|
Cell::new(&project.name).add_attribute(Attribute::Bold),
|
||||||
|
]);
|
||||||
|
|
||||||
|
table.add_row(vec![
|
||||||
|
Cell::new("Container Name").fg(Color::White),
|
||||||
|
Cell::new(project.container_name()).add_attribute(Attribute::Bold),
|
||||||
|
]);
|
||||||
|
|
||||||
|
match &self.ctx.instance {
|
||||||
|
Some(instance) => {
|
||||||
|
let exists = self
|
||||||
|
.ctx
|
||||||
|
.docker
|
||||||
|
.container_exists_by_id(&instance.container_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
table.add_row(vec![
|
||||||
|
Cell::new("Status").fg(Color::White),
|
||||||
|
Cell::new("Container not found").fg(Color::Red),
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
let is_running = self
|
||||||
|
.ctx
|
||||||
|
.docker
|
||||||
|
.is_container_running_by_id(&instance.container_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
table.add_row(vec![
|
||||||
|
Cell::new("Status").fg(Color::White),
|
||||||
|
if is_running {
|
||||||
|
Cell::new("Running").fg(Color::Green)
|
||||||
|
} else {
|
||||||
|
Cell::new("Stopped").fg(Color::Yellow)
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
table.add_row(vec![
|
||||||
|
Cell::new("Container ID").fg(Color::White),
|
||||||
|
Cell::new(&instance.container_id[..12]).fg(Color::DarkGrey),
|
||||||
|
]);
|
||||||
|
|
||||||
|
table.add_row(vec![
|
||||||
|
Cell::new("PostgreSQL Version").fg(Color::White),
|
||||||
|
Cell::new(instance.postgres_version.to_string())
|
||||||
|
.add_attribute(Attribute::Bold),
|
||||||
|
]);
|
||||||
|
|
||||||
|
table.add_row(vec![
|
||||||
|
Cell::new("Port").fg(Color::White),
|
||||||
|
Cell::new(instance.port.to_string()).add_attribute(Attribute::Bold),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Check for drift
|
||||||
|
if instance.postgres_version != project.config.version {
|
||||||
|
table.add_row(vec![
|
||||||
|
Cell::new("⚠ Version Drift").fg(Color::Yellow),
|
||||||
|
Cell::new(format!(
|
||||||
|
"Config: {}, Container: {}",
|
||||||
|
project.config.version, instance.postgres_version
|
||||||
|
))
|
||||||
|
.fg(Color::Yellow),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if instance.port != project.config.port {
|
||||||
|
table.add_row(vec![
|
||||||
|
Cell::new("⚠ Port Drift").fg(Color::Yellow),
|
||||||
|
Cell::new(format!(
|
||||||
|
"Config: {}, Container: {}",
|
||||||
|
project.config.port, instance.port
|
||||||
|
))
|
||||||
|
.fg(Color::Yellow),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
table.add_row(vec![
|
||||||
|
Cell::new("Status").fg(Color::White),
|
||||||
|
Cell::new("Not initialized").fg(Color::Yellow),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("{}", table);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn init_project(&self) -> Result<()> {
|
pub async fn init_project(&self) -> Result<()> {
|
||||||
if let Some(project) = &self.project {
|
let reconciler = Reconciler { ctx: &self.ctx };
|
||||||
return self.reconcile(project).await;
|
|
||||||
|
if let Some(project) = &self.ctx.project {
|
||||||
|
return reconciler.reconcile(project).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("Initializing new 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();
|
versions.sort();
|
||||||
let latest_version = versions
|
let latest_version = versions
|
||||||
.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(&self.ctx.state)?,
|
||||||
};
|
};
|
||||||
let project = Project::new(config)?;
|
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!(
|
println!(
|
||||||
"Container stopped unexpectedly after start (attempt {}/{})",
|
"\nCreated pgd.toml in {}\n",
|
||||||
attempt, MAX_RETRIES
|
project.path.display().to_string().bright_white().bold()
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
let mut table = create_ui_table("Project Configuration".to_string());
|
||||||
Err(e) => {
|
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!(
|
println!(
|
||||||
"Failed to start container (attempt {}/{}): {}",
|
"\n{} {}",
|
||||||
attempt, MAX_RETRIES, e
|
"✓".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(())
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
use miette::miette;
|
use miette::{Diagnostic, miette};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
use bollard::{
|
use bollard::{
|
||||||
Docker,
|
Docker,
|
||||||
|
container::LogOutput,
|
||||||
query_parameters::{
|
query_parameters::{
|
||||||
CreateContainerOptions, CreateImageOptions, InspectContainerOptions, ListImagesOptions,
|
CreateContainerOptions, CreateImageOptions, InspectContainerOptions, ListImagesOptions,
|
||||||
StartContainerOptions, StopContainerOptions,
|
LogsOptions, StartContainerOptions, StopContainerOptions,
|
||||||
},
|
},
|
||||||
secret::ContainerCreateBody,
|
secret::ContainerCreateBody,
|
||||||
};
|
};
|
||||||
|
use colored::Colorize;
|
||||||
|
use futures::{Stream, StreamExt};
|
||||||
use indicatif::MultiProgress;
|
use indicatif::MultiProgress;
|
||||||
use miette::{Context, IntoDiagnostic, Result};
|
use miette::{Context, IntoDiagnostic, Result};
|
||||||
use tracing::info;
|
use tracing::{debug, info};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::PostgresVersion,
|
config::PostgresVersion,
|
||||||
@@ -25,6 +29,11 @@ fn format_image(ver: &PostgresVersion) -> String {
|
|||||||
format!("{DOCKERHUB_POSTGRES}:{}", ver)
|
format!("{DOCKERHUB_POSTGRES}:{}", ver)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Error, Debug, Diagnostic)]
|
||||||
|
#[error("Docker operation failed")]
|
||||||
|
#[diagnostic(code(pgd::docker))]
|
||||||
|
pub enum Error {}
|
||||||
|
|
||||||
pub struct DockerController {
|
pub struct DockerController {
|
||||||
daemon: Docker,
|
daemon: Docker,
|
||||||
}
|
}
|
||||||
@@ -34,10 +43,10 @@ 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");
|
debug!("Connected to docker!");
|
||||||
|
|
||||||
docker
|
docker
|
||||||
.list_images(Some(ListImagesOptions::default()))
|
.list_images(Some(ListImagesOptions::default()))
|
||||||
@@ -58,11 +67,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 +165,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,10 +264,108 @@ 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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use indicatif::{MultiProgress, ProgressBar, ProgressState, ProgressStyle};
|
|||||||
fn new_download_pb(multi: &MultiProgress, layer_id: &str) -> ProgressBar {
|
fn new_download_pb(multi: &MultiProgress, layer_id: &str) -> ProgressBar {
|
||||||
let pb = multi.add(ProgressBar::new(0));
|
let pb = multi.add(ProgressBar::new(0));
|
||||||
pb.set_style(
|
pb.set_style(
|
||||||
ProgressStyle::with_template(&"{spinner:.green} [{elapsed_precise}] {msg} [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({eta})".to_string())
|
ProgressStyle::with_template("{spinner:.green} [{elapsed_precise}] {msg} [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({eta})")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.with_key("eta", |state: &ProgressState, w: &mut dyn Write| {
|
.with_key("eta", |state: &ProgressState, w: &mut dyn Write| {
|
||||||
write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap()
|
write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap()
|
||||||
|
|||||||
242
src/controller/reconciler.rs
Normal file
242
src/controller/reconciler.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
use miette::Result;
|
use miette::Result;
|
||||||
use rand::{Rng, distr::Alphanumeric};
|
use rand::{Rng, distr::Alphanumeric};
|
||||||
|
|
||||||
|
use crate::state::StateManager;
|
||||||
const DEFAULT_POSTGRES_PORT: u16 = 5432;
|
const DEFAULT_POSTGRES_PORT: u16 = 5432;
|
||||||
const PORT_SEARCH_RANGE: u16 = 100;
|
const PORT_SEARCH_RANGE: u16 = 100;
|
||||||
|
|
||||||
pub fn find_available_port() -> Result<u16> {
|
pub fn find_available_port(state: &StateManager) -> Result<u16> {
|
||||||
use std::net::TcpListener;
|
use std::net::TcpListener;
|
||||||
|
|
||||||
for port in DEFAULT_POSTGRES_PORT..(DEFAULT_POSTGRES_PORT + PORT_SEARCH_RANGE) {
|
let starting_port = state
|
||||||
|
.get_highest_used_port()
|
||||||
|
.unwrap_or(DEFAULT_POSTGRES_PORT);
|
||||||
|
|
||||||
|
for port in starting_port..(starting_port + PORT_SEARCH_RANGE) {
|
||||||
if TcpListener::bind(("127.0.0.1", port)).is_ok() {
|
if TcpListener::bind(("127.0.0.1", port)).is_ok() {
|
||||||
return Ok(port);
|
return Ok(port);
|
||||||
}
|
}
|
||||||
|
|||||||
54
src/main.rs
54
src/main.rs
@@ -2,41 +2,59 @@ mod cli;
|
|||||||
mod config;
|
mod config;
|
||||||
mod state;
|
mod state;
|
||||||
|
|
||||||
mod consts {
|
mod consts;
|
||||||
pub const USERNAME: &str = "postgres";
|
|
||||||
pub const DATABASE: &str = "postgres";
|
|
||||||
}
|
|
||||||
|
|
||||||
mod controller;
|
mod controller;
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
use clap_verbosity_flag::Verbosity;
|
||||||
use cli::Cli;
|
use cli::Cli;
|
||||||
use miette::Result;
|
use miette::Result;
|
||||||
use tracing::info;
|
use tracing::debug;
|
||||||
|
|
||||||
use crate::controller::Controller;
|
use crate::{
|
||||||
|
cli::ControlCommands,
|
||||||
|
controller::{Context, Controller},
|
||||||
|
};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
println!("{}", include_str!("./banner.txt"));
|
|
||||||
|
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
init_tracing(cli.verbose);
|
init_tracing(cli.verbosity);
|
||||||
|
|
||||||
info!("pgx.start");
|
debug!("pgd.start");
|
||||||
let controller = Controller::new().await?;
|
|
||||||
|
macro_rules! do_cmd {
|
||||||
|
($name:expr, $method:ident $(, $arg:expr)*) => {{
|
||||||
|
let ctx = Context::new($name).await?;
|
||||||
|
Controller::new(ctx).$method($($arg),*).await?;
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
cli::Commands::Init => controller.init_project().await?,
|
cli::Commands::Init => {
|
||||||
cli::Commands::Instance { name, cmd } => todo!(),
|
do_cmd!(None, init_project);
|
||||||
cli::Commands::Sync => todo!(),
|
}
|
||||||
|
cli::Commands::Instance { name, cmd } => match cmd {
|
||||||
|
ControlCommands::Start => do_cmd!(name, start),
|
||||||
|
ControlCommands::Stop => do_cmd!(name, stop),
|
||||||
|
ControlCommands::Restart => do_cmd!(name, restart),
|
||||||
|
ControlCommands::Destroy { force } => do_cmd!(name, destroy, force),
|
||||||
|
ControlCommands::Logs { follow } => do_cmd!(name, logs, follow),
|
||||||
|
ControlCommands::Status => do_cmd!(name, status),
|
||||||
|
// can't override an instance for this command, because password is in config
|
||||||
|
ControlCommands::Conn { format } => do_cmd!(None, show_connection, format),
|
||||||
|
ControlCommands::Wipe { force } => do_cmd!(name, wipe, force),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn init_tracing(verbose: bool) {
|
fn init_tracing(verbosity: Verbosity) {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_max_level(verbosity)
|
||||||
tracing_subscriber::fmt::init();
|
.without_time()
|
||||||
|
.with_target(false)
|
||||||
|
.init();
|
||||||
}
|
}
|
||||||
|
|||||||
132
src/state.rs
132
src/state.rs
@@ -1,92 +1,59 @@
|
|||||||
use miette::{Context, IntoDiagnostic, Result};
|
use miette::{Context, IntoDiagnostic, Result};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::cell::RefCell;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use crate::config::PostgresVersion;
|
use crate::config::PostgresVersion;
|
||||||
|
|
||||||
/// State information for a single PostgreSQL instance
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct InstanceState {
|
pub struct InstanceState {
|
||||||
/// Docker container ID
|
|
||||||
pub container_id: String,
|
pub container_id: String,
|
||||||
|
|
||||||
/// PostgreSQL version running in the container
|
|
||||||
pub postgres_version: PostgresVersion,
|
pub postgres_version: PostgresVersion,
|
||||||
|
|
||||||
/// Port the container is bound to
|
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
|
|
||||||
/// Timestamp when the instance was created (Unix timestamp)
|
|
||||||
pub created_at: u64,
|
pub created_at: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Manages the global state file at ~/.pgx/state.json
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
struct State {
|
||||||
pub struct StateManager {
|
|
||||||
/// Map of project name to instance state
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
instances: HashMap<String, InstanceState>,
|
instances: HashMap<String, InstanceState>,
|
||||||
}
|
}
|
||||||
|
impl State {
|
||||||
/// Get the path to the state file (~/.pgx/state.json)
|
fn new() -> Result<Self> {
|
||||||
|
|
||||||
fn state_file_path() -> Result<PathBuf> {
|
|
||||||
let home = std::env::var("HOME")
|
|
||||||
.into_diagnostic()
|
|
||||||
.wrap_err("Failed to get HOME environment variable")?;
|
|
||||||
|
|
||||||
Ok(PathBuf::from(home).join(".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> {
|
|
||||||
let state_path = state_file_path()?;
|
let state_path = state_file_path()?;
|
||||||
|
|
||||||
if !state_path.exists() {
|
if !state_path.exists() {
|
||||||
// Create the directory if it doesn't exist
|
|
||||||
if let Some(parent) = state_path.parent() {
|
if let Some(parent) = state_path.parent() {
|
||||||
std::fs::create_dir_all(parent)
|
std::fs::create_dir_all(parent)
|
||||||
.into_diagnostic()
|
.into_diagnostic()
|
||||||
.wrap_err("Failed to create .pgx directory")?;
|
.wrap_err("Failed to create .pgd directory")?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return empty state
|
return Ok(Self::default());
|
||||||
return Ok(StateManager {
|
|
||||||
instances: HashMap::new(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let content = std::fs::read_to_string(&state_path)
|
let content = std::fs::read_to_string(&state_path)
|
||||||
.into_diagnostic()
|
.into_diagnostic()
|
||||||
.wrap_err_with(|| format!("Failed to read state file: {}", state_path.display()))?;
|
.wrap_err_with(|| format!("Failed to read state file: {}", state_path.display()))?;
|
||||||
|
|
||||||
let state: StateManager = serde_json::from_str(&content)
|
let state: Self = serde_json::from_str(&content)
|
||||||
.into_diagnostic()
|
.into_diagnostic()
|
||||||
.wrap_err("Failed to parse state.json")?;
|
.wrap_err("Failed to parse state.json")?;
|
||||||
|
|
||||||
Ok(state)
|
Ok(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save the state manager to disk
|
fn save(&self) -> Result<()> {
|
||||||
pub fn save(&self) -> Result<()> {
|
|
||||||
let state_path = state_file_path()?;
|
let state_path = state_file_path()?;
|
||||||
|
|
||||||
// Ensure directory exists
|
|
||||||
if let Some(parent) = state_path.parent() {
|
if let Some(parent) = state_path.parent() {
|
||||||
std::fs::create_dir_all(parent)
|
std::fs::create_dir_all(parent)
|
||||||
.into_diagnostic()
|
.into_diagnostic()
|
||||||
.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)
|
||||||
@@ -99,43 +66,34 @@ 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
|
pub struct StateManager(RefCell<State>);
|
||||||
pub fn get_mut(&mut self, project_name: &str) -> Option<&mut InstanceState> {
|
|
||||||
self.instances.get_mut(project_name)
|
impl StateManager {
|
||||||
|
pub fn new() -> Result<Self> {
|
||||||
|
Ok(Self(RefCell::new(State::new()?)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the state for a specific project
|
pub fn save(&self) -> Result<()> {
|
||||||
pub fn set(&mut self, project_name: String, state: InstanceState) {
|
self.0.borrow().save()?;
|
||||||
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(())
|
Ok(())
|
||||||
} else {
|
|
||||||
miette::bail!("No state found for project: {}", project_name)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove the state for a specific project
|
pub fn get(&self, project_name: &str) -> Option<InstanceState> {
|
||||||
pub fn remove(&mut self, project_name: &str) -> Option<InstanceState> {
|
self.0.borrow().instances.get(project_name).cloned()
|
||||||
self.instances.remove(project_name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all instances
|
pub fn upsert(&self, project_name: String, state: InstanceState) {
|
||||||
pub fn all_instances(&self) -> &HashMap<String, InstanceState> {
|
self.0.borrow_mut().instances.insert(project_name, state);
|
||||||
&self.instances
|
}
|
||||||
|
|
||||||
|
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)]
|
fn state_file_path() -> Result<PathBuf> {
|
||||||
mod tests {
|
let home = std::env::home_dir().wrap_err("Failed to get HOME environment variable")?;
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
Ok(home.join(".pgd").join("state.json"))
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user