Compare commits

...

4 Commits

11 changed files with 707 additions and 307 deletions

69
Cargo.lock generated
View File

@@ -249,6 +249,20 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
[[package]]
name = "cliclack"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2381872509dfa50d8b92b92a5da8367ba68458ab9494be4134b57ad6ca26295f"
dependencies = [
"console 0.15.11",
"indicatif",
"once_cell",
"strsim",
"textwrap",
"zeroize",
]
[[package]]
name = "colorchoice"
version = "1.0.4"
@@ -275,6 +289,19 @@ dependencies = [
"unicode-width 0.2.2",
]
[[package]]
name = "console"
version = "0.15.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
dependencies = [
"encode_unicode",
"libc",
"once_cell",
"unicode-width 0.2.2",
"windows-sys 0.59.0",
]
[[package]]
name = "console"
version = "0.16.1"
@@ -382,6 +409,15 @@ dependencies = [
"litrs",
]
[[package]]
name = "dsn"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a68ec86c8ab056c40c4d3f6a543ad0ad6a251d7d01dac251feed242aa44e754a"
dependencies = [
"percent-encoding",
]
[[package]]
name = "dyn-clone"
version = "1.0.20"
@@ -841,7 +877,7 @@ version = "0.18.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9375e112e4b463ec1b1c6c011953545c65a30164fbab5b581df32b3abf0dcb88"
dependencies = [
"console",
"console 0.16.1",
"portable-atomic",
"unicode-segmentation",
"unicode-width 0.2.2",
@@ -1064,15 +1100,19 @@ version = "0.0.1"
dependencies = [
"bollard",
"clap",
"cliclack",
"colored",
"comfy-table",
"dsn",
"futures",
"indicatif",
"miette",
"parking_lot",
"rand",
"serde",
"serde_json",
"serde_with",
"thiserror",
"tokio",
"toml",
"tracing",
@@ -1406,6 +1446,12 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "smawk"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c"
[[package]]
name = "socket2"
version = "0.6.1"
@@ -1487,6 +1533,7 @@ version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
dependencies = [
"smawk",
"unicode-linebreak",
"unicode-width 0.2.2",
]
@@ -2174,6 +2221,26 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zerotrie"
version = "0.2.3"

View File

@@ -9,15 +9,19 @@ license = "MIT"
[dependencies]
bollard = "0.19.4"
clap = { version = "4.5.53", features = ["derive"] }
cliclack = "0.3.7"
colored = "3.0.0"
comfy-table = "7.2.1"
dsn = "1.2.1"
futures = "0.3.31"
indicatif = { version = "0.18.3", features = ["improved_unicode"] }
miette = { version = "7.6.0", features = ["fancy"] }
parking_lot = "0.12.5"
rand = "0.9.2"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
serde_with = "3.16.1"
thiserror = "2.0.17"
tokio = { version = "1.48.0", features = ["full"] }
toml = "0.9.8"
tracing = "0.1.43"

139
README.md Normal file
View File

@@ -0,0 +1,139 @@
```
▄▄▄▄ ▐▌
█ █ ▐▌
█▄▄▄▀ ▗▞▀▜▌
█ ▗▄▖▝▚▄▟▌
▀ ▐▌ ▐▌
▝▀▜▌
▐▙▄▞▘
```
---
> 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
# Check instance status and configuration drift
pgd instance status
# View PostgreSQL logs
pgd instance logs
# Follow logs in real-time
pgd instance logs --f
# 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
# Remove the container
pgd instance destroy
# Wipe all database data
pgd instance wipe
```
These commands require confirmation to prevent accidental data loss.
### 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 correct things.
## Project Structure
Your project tree after initialization:
```
my-project/
├── pgd.toml # Database configuration
├── src/ # Your application code
└── ...
```
The `pgd.toml` file should be committed to version control so team members can reproduce the exact database setup.

View File

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

View File

@@ -21,12 +21,10 @@ pub struct Cli {
#[derive(Clone, clap::ValueEnum)]
pub enum ConnectionFormat {
/// Human-readable text format
Text,
/// JSON format
Json,
/// Environment variable format
Env,
/// DSN Url
DSN,
// Human readable format
Human,
}
#[derive(Subcommand)]
@@ -38,13 +36,22 @@ pub enum ControlCommands {
/// Restart postgres instance
Restart,
/// (WARNING!) Destroy postgres instance
Destroy,
Destroy { accept: bool },
/// (WARNING!) Destruct database
Wipe { accept: bool },
/// Status of instance
Status,
/// View logs produced by postgres
Logs { follow: bool },
Logs {
#[arg(short, long, default_value = "false")]
follow: bool,
},
/// (Sensitive) get connection details
Connection { format: ConnectionFormat },
Conn {
#[arg(short, long, default_value = "dsn")]
format: ConnectionFormat,
},
}
#[derive(Subcommand)]

2
src/consts.rs Normal file
View File

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

View File

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

View File

@@ -1,15 +1,18 @@
use miette::miette;
use std::str::FromStr;
use miette::{Diagnostic, miette};
use std::{io::Write, str::FromStr};
use thiserror::Error;
use bollard::{
Docker,
container::LogOutput,
query_parameters::{
CreateContainerOptions, CreateImageOptions, InspectContainerOptions, ListImagesOptions,
StartContainerOptions, StopContainerOptions,
LogsOptions, StartContainerOptions, StopContainerOptions,
},
secret::ContainerCreateBody,
};
use colored::Colorize;
use futures::{Stream, StreamExt};
use indicatif::MultiProgress;
use miette::{Context, IntoDiagnostic, Result};
use tracing::info;
@@ -26,6 +29,11 @@ fn format_image(ver: &PostgresVersion) -> String {
format!("{DOCKERHUB_POSTGRES}:{}", ver)
}
#[derive(Error, Debug, Diagnostic)]
#[error("Docker operation failed")]
#[diagnostic(code(pgd::docker))]
pub enum Error {}
pub struct DockerController {
daemon: Docker,
}
@@ -262,4 +270,24 @@ impl DockerController {
PostgresVersion::from_str(version_str)
.map_err(|_| miette!("Invalid version in label: {}", version_str))
}
pub async fn stream_logs(
&self,
container_id: &str,
follow: bool,
) -> impl Stream<Item = Result<LogOutput>> {
let options = Some(LogsOptions {
follow,
stdout: true,
stderr: true,
..Default::default()
});
let logs = self
.daemon
.logs(container_id, options)
.map(|k| k.into_diagnostic().wrap_err("Failed streaming logs"));
logs
}
}

View File

@@ -0,0 +1,241 @@
use std::time::Duration;
use miette::{Diagnostic, bail};
use colored::Colorize;
use miette::Result;
use thiserror::Error;
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?
{
println!("{}", "Container is already running".white());
return Ok(());
}
use indicatif::{ProgressBar, ProgressStyle};
let spinner = ProgressBar::new_spinner();
spinner.enable_steady_tick(Duration::from_millis(100));
spinner.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.cyan} {msg}")
.unwrap(),
);
spinner.set_message("Starting container...");
for attempt in 1..=MAX_RETRIES {
spinner.set_message(format!(
"Starting container (attempt {}/{})",
attempt, MAX_RETRIES
));
let result = self.try_starting_container(&container_id, &spinner).await;
match result {
Ok(_) => {
spinner.finish_with_message(format!(
"{}",
"Container started successfully".green().bold()
));
return Ok(());
}
Err(err) => {
spinner.set_message(format!(
"{} {}/{} failed: {}",
"Attempt".yellow(),
attempt,
MAX_RETRIES,
err
));
}
}
if attempt < MAX_RETRIES {
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
}
}
spinner.finish_with_message(format!("{}", "Failed to start container".red()));
miette::bail!("Failed to start container after {} attempts", MAX_RETRIES)
}
async fn try_starting_container(
&self,
container_id: &String,
spinner: &indicatif::ProgressBar,
) -> Result<(), miette::Error> {
match self.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?
{
return Ok(());
} else {
miette::bail!("Container stopped unexpectedly after start");
}
}
Err(e) => {
miette::bail!("Failed to start: {}", e);
}
}
}
async fn update_project_container(&self, project: &Project) -> Result<String, miette::Error> {
println!(
"{} {}",
"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?;
println!("{}", "Container created successfully".green());
self.ctx.state.set(
project.name.clone(),
crate::state::InstanceState::new(
id.clone(),
project.config.version,
project.config.port,
),
);
self.ctx.state.save()?;
Ok(id)
}
async fn ensure_container_exists(
&self,
instance: &InstanceState,
) -> Result<Option<String>, miette::Error> {
let mut container_id = None;
let id = &instance.container_id;
if self.ctx.docker.container_exists_by_id(id).await? {
container_id = Some(id.clone());
}
Ok(container_id)
}
async fn ensure_matches_project_version(
&self,
project: &Project,
_container_id: &String,
container_version: PostgresVersion,
) -> Result<(), miette::Error> {
let _: () = if container_version != project.config.version {
let needs_upgrade = container_version < project.config.version;
if needs_upgrade {
bail!("Upgrades are currently unsupported! :(");
// println!(
// "Upgrading PostgreSQL from {} to {}...",
// container_version, project.config.version
// );
// self.docker.stop_container(container_id, 10).await?;
// self.docker
// .upgrade_container_image(
// container_id,
// container_name,
// &project.config.version,
// &project.config.password,
// project.config.port,
// )
// .await?;
// if let Some(instance_state) = state.get_mut(&project.name) {
// instance_state.postgres_version = project.config.version.to_string();
// state.save()?;
// }
} else {
miette::bail!(
"Cannot downgrade PostgreSQL from {} to {}. Downgrades are not supported.",
container_version,
project.config.version
);
}
};
Ok(())
}
}

View File

@@ -2,10 +2,7 @@ mod cli;
mod config;
mod state;
mod consts {
pub const USERNAME: &str = "postgres";
pub const DATABASE: &str = "postgres";
}
mod consts;
mod controller;
@@ -14,7 +11,10 @@ use cli::Cli;
use miette::Result;
use tracing::info;
use crate::controller::Controller;
use crate::{
cli::ControlCommands,
controller::{Context, Controller},
};
#[tokio::main]
async fn main() -> Result<()> {
@@ -24,18 +24,34 @@ async fn main() -> Result<()> {
init_tracing(cli.verbose);
info!("pgd.start");
let controller = Controller::new().await?;
match cli.command {
cli::Commands::Init => controller.init_project().await?,
cli::Commands::Instance { name, cmd } => todo!(),
cli::Commands::Init => {
let ctx = Context::new(None).await?;
Controller::new(ctx).init_project().await?;
}
cli::Commands::Instance { name, cmd } => match cmd {
ControlCommands::Start => {}
ControlCommands::Stop => {}
ControlCommands::Restart => {}
ControlCommands::Destroy { accept } => {}
ControlCommands::Logs { follow } => {
let ctx = Context::new(name).await?;
Controller::new(ctx).logs(follow).await?;
}
ControlCommands::Status => {}
// can't override an instance for this command, because password is in config
ControlCommands::Conn { format } => {
let ctx = Context::new(None).await?;
Controller::new(ctx).show_connection(format).await?;
}
ControlCommands::Wipe { accept } => {}
},
}
Ok(())
}
fn init_tracing(verbose: bool) {
fn init_tracing(_verbose: bool) {
tracing_subscriber::fmt::init();
}

View File

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