feat(controller): start, stop, restart, destroy and wipe commands

This commit is contained in:
2025-12-10 19:20:42 +01:00
parent a01f511311
commit 6d16748cb3
7 changed files with 440 additions and 42 deletions

View File

@@ -22,7 +22,7 @@ pub struct Cli {
#[derive(Clone, clap::ValueEnum)]
pub enum ConnectionFormat {
/// DSN Url
DSN,
Dsn,
// Human readable format
Human,
}
@@ -36,9 +36,9 @@ pub enum ControlCommands {
/// Restart postgres instance
Restart,
/// (WARNING!) Destroy postgres instance
Destroy { accept: bool },
Destroy { force: bool },
/// (WARNING!) Destruct database
Wipe { accept: bool },
Wipe { force: bool },
/// Status of instance
Status,

View File

@@ -3,7 +3,7 @@ use miette::miette;
use colored::Colorize;
use comfy_table::{Attribute, Cell, Color, ContentArrangement, Table, presets::UTF8_FULL};
use futures::TryStreamExt;
use miette::Result;
use miette::{IntoDiagnostic, Result};
use crate::{
cli::ConnectionFormat,
@@ -90,7 +90,7 @@ impl Controller {
reconciler.reconcile(project).await?;
match format {
ConnectionFormat::DSN => {
ConnectionFormat::Dsn => {
println!(
"postgres://{}:{}@127.0.0.1:{}/{}",
USERNAME, project.config.password, project.config.port, DATABASE
@@ -104,6 +104,297 @@ impl Controller {
Ok(())
}
pub async fn start(&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 already running".yellow());
return Ok(());
}
println!("{}", "Starting container...".cyan());
self.ctx
.docker
.start_container_by_id(&instance.container_id)
.await?;
println!(
"{} {} {}",
"".green().bold(),
"Container".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()?;
println!("{}", "Restarting container...".cyan());
self.ctx
.docker
.restart_container(&instance.container_id, 10)
.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<()> {
let reconciler = Reconciler { ctx: &self.ctx };
@@ -131,7 +422,7 @@ impl Controller {
project.path.display().to_string().bright_white().bold()
);
let mut table = create_ui_table("Project Configuration");
let mut table = create_ui_table("Project Configuration".to_string());
table.add_row(vec![
Cell::new("Project").fg(Color::White),
Cell::new(&project.name).add_attribute(Attribute::Bold),
@@ -153,14 +444,18 @@ impl Controller {
reconciler.reconcile(&project).await?;
println!("\n{}", "✓ Project initialized successfully!".green().bold());
println!(
"\n{} {}",
"".green().bold(),
"Project initialized successfully!".green(),
);
Ok(())
}
}
fn format_conn_human(project: &Project) {
let mut table = create_ui_table("Instance");
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),
@@ -190,7 +485,7 @@ fn format_conn_human(project: &Project) {
println!("{}", table);
}
fn create_ui_table(header: &'static str) -> Table {
fn create_ui_table(header: String) -> Table {
let mut table = Table::new();
table
.load_preset(UTF8_FULL)

View File

@@ -287,4 +287,85 @@ impl DockerController {
.logs(container_id, options)
.map(|k| k.into_diagnostic().wrap_err("Failed streaming logs"))
}
pub async fn remove_container(&self, container_id: &str, force: bool) -> Result<()> {
use bollard::query_parameters::RemoveContainerOptions;
self.daemon
.remove_container(
container_id,
Some(RemoveContainerOptions {
force,
v: true, // Remove associated volumes
..Default::default()
}),
)
.await
.into_diagnostic()
.wrap_err("Failed to remove container")?;
Ok(())
}
pub async fn restart_container(&self, container_id: &str, timeout: i32) -> Result<()> {
use bollard::query_parameters::RestartContainerOptions;
self.daemon
.restart_container(
container_id,
Some(RestartContainerOptions {
t: Some(timeout),
signal: None,
}),
)
.await
.into_diagnostic()
.wrap_err("Failed to restart container")?;
Ok(())
}
pub async fn exec_in_container(&self, container_id: &str, cmd: Vec<&str>) -> Result<String> {
use bollard::container::LogOutput;
use bollard::exec::{CreateExecOptions, StartExecOptions};
let exec = self
.daemon
.create_exec(
container_id,
CreateExecOptions {
cmd: Some(cmd),
attach_stdout: Some(true),
attach_stderr: Some(true),
..Default::default()
},
)
.await
.into_diagnostic()
.wrap_err("Failed to create exec")?;
let mut output = String::new();
let start_exec_result = self
.daemon
.start_exec(&exec.id, Some(StartExecOptions::default()))
.await
.into_diagnostic()?;
if let bollard::exec::StartExecResults::Attached {
output: mut exec_output,
..
} = start_exec_result
{
while let Some(Ok(msg)) = exec_output.next().await {
match msg {
LogOutput::StdOut { message } | LogOutput::StdErr { message } => {
output.push_str(&String::from_utf8_lossy(&message));
}
_ => {}
}
}
}
Ok(output)
}
}

View File

@@ -120,7 +120,7 @@ impl<'a> Reconciler<'a> {
async fn try_starting_container(
&self,
container_id: &String,
container_id: &str,
spinner: &indicatif::ProgressBar,
) -> Result<(), miette::Error> {
match self.ctx.docker.start_container_by_id(container_id).await {
@@ -175,7 +175,7 @@ impl<'a> Reconciler<'a> {
)
.await?;
info!("{}", "Container created successfully".green());
self.ctx.state.set(
self.ctx.state.upsert(
project.name.clone(),
crate::state::InstanceState::new(
id.clone(),

View File

@@ -6,13 +6,11 @@ mod consts;
mod controller;
use std::env::args;
use clap::Parser;
use clap_verbosity_flag::Verbosity;
use cli::Cli;
use miette::Result;
use tracing::{debug, info};
use tracing::debug;
use crate::{
cli::ControlCommands,
@@ -26,27 +24,27 @@ async fn main() -> Result<()> {
debug!("pgd.start");
macro_rules! do_cmd {
($name:expr, $method:ident $(, $arg:expr)*) => {{
let ctx = Context::new($name).await?;
Controller::new(ctx).$method($($arg),*).await?;
}};
}
match cli.command {
cli::Commands::Init => {
let ctx = Context::new(None).await?;
Controller::new(ctx).init_project().await?;
do_cmd!(None, init_project);
}
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 => {}
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 } => {
let ctx = Context::new(None).await?;
Controller::new(ctx).show_connection(format).await?;
}
ControlCommands::Wipe { accept } => {}
ControlCommands::Conn { format } => do_cmd!(None, show_connection, format),
ControlCommands::Wipe { force } => do_cmd!(name, wipe, force),
},
}

View File

@@ -84,10 +84,14 @@ impl StateManager {
self.0.borrow().instances.get(project_name).cloned()
}
pub fn set(&self, project_name: String, state: InstanceState) {
pub fn upsert(&self, project_name: String, state: InstanceState) {
self.0.borrow_mut().instances.insert(project_name, state);
}
pub fn remove(&self, project_name: &str) -> Option<InstanceState> {
self.0.borrow_mut().instances.remove(project_name)
}
pub fn get_highest_used_port(&self) -> Option<u16> {
self.0.borrow().instances.values().map(|i| i.port).max()
}