4 Commits

14 changed files with 1403 additions and 30 deletions

766
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,8 @@
[workspace] [workspace]
members = [ members = [
"apps/container",
"public/database", "public/database",
"public/migration", "public/migration"
] ]
resolver = "3" resolver = "3"

1
apps/container/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
generated/

17
apps/container/Cargo.toml Normal file
View File

@@ -0,0 +1,17 @@
[package]
name = "container-simulate"
version = "0.1.0"
edition = "2024"
[lib]
name = "container"
path = "src/lib.rs"
[dependencies]
async-trait = "0.1.89"
testcontainers = "0.24.0"
shared = { path = "../../public/shared" }
tokio = { version = "1.47.0", features = ["full"] }
url = "2.5.7"
clap = { version = "4.5.48", features = ["derive", "env"] }
path-clean = "1.0.1"

59
apps/container/src/db.rs Normal file
View File

@@ -0,0 +1,59 @@
pub mod config;
pub mod postgresql;
pub mod sqlite;
use async_trait::async_trait;
use shared::db_type::DBType;
use std::future::Future;
use std::{pin::Pin, sync::Arc};
use url::Host;
use testcontainers::{ContainerAsync, GenericImage, TestcontainersError};
use crate::{ConfigInfoType, WithContainer, WithoutContainer};
pub type UnStartedContainer =
Pin<Box<dyn Future<Output = Result<ContainerAsync<GenericImage>, TestcontainersError>> + Send>>;
pub type DBConfigInfoType = ConfigInfoType<ContainerizedDBInfo, PreExistingDBInfo>;
#[derive(Clone)]
pub struct PreExistingDBInfo {
pub db_type: DBType,
pub url: String,
pub on_delete: Arc<dyn Fn() + Send + Sync>,
}
impl WithoutContainer for PreExistingDBInfo {
fn on_delete(&self) {
(self.on_delete)();
}
}
#[derive(Clone)]
pub struct ContainerizedDBInfo {
pub db_type: DBType,
pub container: Arc<ContainerAsync<GenericImage>>,
pub container_name: String,
pub database_name: String,
pub host: Host,
pub port: u16,
pub url: String,
pub user: String,
pub password: String,
}
impl WithContainer for ContainerizedDBInfo {
fn container(&self) -> &Arc<ContainerAsync<GenericImage>> {
&self.container
}
}
#[async_trait]
pub trait DBInfo<T> {
async fn new(config: Option<T>) -> Self
where
Self: Sized;
async fn get_db_container_config_info(&self) -> DBConfigInfoType;
fn get_unstarted_container(&self) -> Result<UnStartedContainer, ()>;
}

View File

@@ -0,0 +1,53 @@
pub struct OptionalContainerConfig {
pub image: Option<String>,
pub tag: Option<String>,
pub container_name: Option<String>,
pub database_name: Option<String>,
pub user: Option<String>,
pub password: Option<String>,
}
#[derive(Clone)]
pub struct ContainerConfig {
pub image: String,
pub tag: String,
pub container_name: String,
pub database_name: String,
pub user: String,
pub password: String,
}
impl OptionalContainerConfig {
pub fn fill_with(&self, other: &ContainerConfig) -> ContainerConfig {
ContainerConfig {
image: self.image.clone().unwrap_or_else(|| other.image.clone()),
tag: self.tag.clone().unwrap_or_else(|| other.tag.clone()),
container_name: self
.container_name
.clone()
.unwrap_or_else(|| other.container_name.clone()),
database_name: self
.database_name
.clone()
.unwrap_or_else(|| other.database_name.clone()),
user: self.user.clone().unwrap_or_else(|| other.user.clone()),
password: self
.password
.clone()
.unwrap_or_else(|| other.password.clone()),
}
}
}
impl Default for OptionalContainerConfig {
fn default() -> Self {
Self {
image: None,
tag: None,
container_name: None,
database_name: None,
user: None,
password: None,
}
}
}

View File

@@ -0,0 +1,95 @@
use std::sync::Arc;
use async_trait::async_trait;
use testcontainers::{
GenericImage, ImageExt,
core::{IntoContainerPort, WaitFor},
runners::AsyncRunner,
};
use crate::{
ConfigInfoType,
db::{
ContainerizedDBInfo, DBConfigInfoType, DBInfo, UnStartedContainer,
config::{ContainerConfig, OptionalContainerConfig},
},
};
pub fn get_default_config() -> ContainerConfig {
ContainerConfig {
container_name: "yanpm-postgres".to_string(),
database_name: "postgres".to_string(),
user: "postgres".to_string(),
password: "postgres".to_string(),
image: "postgres".to_string(),
tag: "16-alpine".to_string(),
}
}
pub struct PostgreSQLContainer {
pub config: ContainerConfig,
}
#[async_trait]
impl DBInfo<OptionalContainerConfig> for PostgreSQLContainer {
async fn get_db_container_config_info(&self) -> DBConfigInfoType {
let pg_container = self
.get_unstarted_container()
.unwrap()
.await
.expect("Failed to start PostgreSQL container");
let pg_host = pg_container.get_host().await.expect("Failed to get host");
let pg_host_port = pg_container
.get_host_port_ipv4(5432.tcp())
.await
.expect("Failed to get host port");
let pg_url = format!(
"postgres://{}:{}@{}:{}/{}",
self.config.user,
self.config.password,
pg_host,
pg_host_port,
self.config.database_name
);
ConfigInfoType::Containerized(ContainerizedDBInfo {
db_type: crate::db::DBType::PostgreSQL,
container: Arc::new(pg_container),
container_name: self.config.container_name.clone(),
database_name: self.config.database_name.clone(),
host: pg_host,
port: pg_host_port,
url: pg_url,
user: self.config.user.clone(),
password: self.config.password.clone(),
})
}
async fn new(user_default_config: Option<OptionalContainerConfig>) -> Self
where
Self: Sized,
{
let default_config = get_default_config();
let config = user_default_config
.unwrap_or_default()
.fill_with(&default_config);
PostgreSQLContainer {
config: config.clone(),
}
}
fn get_unstarted_container(&self) -> Result<UnStartedContainer, ()> {
Ok(
GenericImage::new(self.config.image.clone(), self.config.tag.clone())
.with_exposed_port(5432.tcp())
.with_wait_for(WaitFor::message_on_stderr(
"database system is ready to accept connections",
))
.with_container_name(self.config.container_name.clone())
.with_env_var("POSTGRES_USER", self.config.user.clone())
.with_env_var("POSTGRES_PASSWORD", self.config.password.clone())
.start(),
)
}
}

View File

@@ -0,0 +1,113 @@
use std::{path::PathBuf, sync::Arc};
use async_trait::async_trait;
use crate::{
ConfigInfoType,
db::{DBConfigInfoType, DBInfo, PreExistingDBInfo, UnStartedContainer},
util::to_absolute_path,
};
#[derive(Clone)]
pub struct ContainerConfig {
// Add any SQLite-specific configuration options here if needed
pub database_name: String,
pub absolute_dir_path: PathBuf,
}
pub struct OptionalContainerConfig {
// Add any optional configuration fields here
pub database_name: Option<String>,
pub absolute_path: Option<PathBuf>,
}
impl OptionalContainerConfig {
pub fn fill_with(&self, other: &ContainerConfig) -> ContainerConfig {
ContainerConfig {
database_name: self
.database_name
.clone()
.unwrap_or_else(|| other.database_name.clone()),
absolute_dir_path: self
.absolute_path
.clone()
.unwrap_or_else(|| other.absolute_dir_path.clone()),
}
}
}
impl Default for OptionalContainerConfig {
fn default() -> Self {
Self {
database_name: None,
absolute_path: None,
}
}
}
pub fn get_default_config() -> ContainerConfig {
ContainerConfig {
database_name: "sqlite".to_string(),
absolute_dir_path: to_absolute_path("./generated/sqlite"),
}
}
pub struct SQLiteContainer {
pub config: ContainerConfig,
}
impl SQLiteContainer {
fn get_db_absolute_path(&self) -> PathBuf {
self.config
.absolute_dir_path
.join(&self.config.database_name)
.with_extension("db")
}
}
#[async_trait]
impl DBInfo<OptionalContainerConfig> for SQLiteContainer {
async fn get_db_container_config_info(&self) -> DBConfigInfoType {
// sqlite filepath url does not include the "sqlite://" prefix
let sqlite_url = format!("{}", self.get_db_absolute_path().to_string_lossy());
// create the file
std::fs::create_dir_all(&self.config.absolute_dir_path)
.expect("Failed to create directories for SQLite database");
std::fs::File::create(self.get_db_absolute_path())
.expect("Failed to create SQLite database file");
//
ConfigInfoType::PreExisting(PreExistingDBInfo {
db_type: crate::db::DBType::SQLite,
url: sqlite_url,
on_delete: {
let db_path = self.get_db_absolute_path();
Arc::new(move || {
// delete the sqlite database file
if db_path.exists() {
if let Err(e) = std::fs::remove_file(&db_path) {
eprintln!("Failed to delete SQLite database file: {}", e);
}
}
})
},
})
}
async fn new(user_default_config: Option<OptionalContainerConfig>) -> Self
where
Self: Sized,
{
let default_config = get_default_config();
let config = user_default_config
.unwrap_or_default()
.fill_with(&default_config);
SQLiteContainer {
config: config.clone(),
}
}
fn get_unstarted_container(&self) -> Result<UnStartedContainer, ()> {
Err(())
}
}

36
apps/container/src/env.rs Normal file
View File

@@ -0,0 +1,36 @@
use std::io::Write;
use shared::db_type::DBType;
#[derive(Clone, Copy)]
pub enum EnvFileType {
DotEnv,
Yaml,
}
#[derive(Clone)]
pub struct EnvFile {
pub file_type: EnvFileType,
pub db_type: DBType,
pub db_url: String,
}
impl EnvFile {
pub fn write(self, path: impl AsRef<std::path::Path>) {
let path_ref = path.as_ref();
println!("Config file path: {}", path_ref.display());
let mut config_file =
std::fs::File::create(path_ref).expect("Failed to create config file");
//
self._write_line(&mut config_file, "DB_TYPE", &self.db_type.to_string());
self._write_line(&mut config_file, "DATABASE_URL", &self.db_url.to_string())
}
fn _write_line(&self, file: &mut std::fs::File, key: &str, value: &str) {
match self.file_type {
EnvFileType::DotEnv => writeln!(file, "{}={}", key, value),
EnvFileType::Yaml => writeln!(file, "{}: \"{}\"", key, value),
}
.expect("Failed to write to config file");
}
}

95
apps/container/src/lib.rs Normal file
View File

@@ -0,0 +1,95 @@
pub mod db;
mod env;
pub mod types;
mod util;
use crate::{
db::DBConfigInfoType,
types::{ConfigInfoType, WithContainer, WithoutContainer},
util::{
await_termination_signal, remove_file_if_exists, stop_container, to_absolute_path,
write_env_files,
},
};
#[derive(Clone)]
pub struct Config {
pub database: DBConfigInfoType,
}
// relative to the pwd
const API_CONFIG_PATH: &str = "../api/generated-config.yaml";
const DB_CONFIG_PATH: &str = "../../public/database/.env.generated";
pub struct DetachedHandle<'a> {
stopped: bool,
pub config: &'a Config,
}
impl<'a> DetachedHandle<'a> {
pub fn new(config: &'a Config) -> Self {
DetachedHandle {
stopped: false,
config,
}
}
pub async fn stop(&mut self) {
if self.stopped {
eprintln!("Attempted to stop an already stopped DetachedHandle.");
return;
}
self.stopped = true;
stop(self.config).await;
}
}
impl<'a> Drop for DetachedHandle<'a> {
fn drop(&mut self) {
if self.stopped {
return;
}
eprintln!(
"Warning: DetachedHandle was dropped without calling stop(). The container may still be running."
);
}
}
async fn start(config: &Config) {
let db_config = &config.database;
//
// write the config files for the api server and database client
println!("Writing config files...");
write_env_files(&db_config);
println!("Config files written to:");
println!(" - {}", to_absolute_path(API_CONFIG_PATH).display());
println!(" - {}", to_absolute_path(DB_CONFIG_PATH).display());
}
async fn stop(config: &Config) {
let db_config = &config.database;
// stop the container
println!("Stopping container...");
stop_container(db_config, "database".to_string()).await;
// remove the generated config file
println!("Removing generated config file...");
remove_file_if_exists(DB_CONFIG_PATH);
remove_file_if_exists(API_CONFIG_PATH);
println!("Container stopped.");
}
pub async fn start_attached(config: &Config) {
start(config).await;
// wait for user input, ctrl+c, or other signals before exiting
println!("Press Ctrl+C, or send SIGTERM to stop the container...");
await_termination_signal().await;
stop(config).await;
}
#[must_use]
pub async fn start_detached(config: &'_ Config) -> DetachedHandle<'_> {
start(config).await;
DetachedHandle::new(config)
}

View File

@@ -0,0 +1,53 @@
use clap::Parser;
use container::Config;
use container::start_attached;
use container::db::DBInfo;
/// Command line arguments
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
/// Database type to use: 'postgres' or 'sqlite'. Can also be set with DB_TYPE env var.
#[arg(long, default_value = "sqlite", env = "DB_TYPE")]
db_type: String,
}
#[tokio::main]
async fn main() {
// Parse command line arguments and environment variables
let args = Args::parse();
println!("Starting container with database type: {}", args.db_type);
let db_config = match args.db_type.to_lowercase().as_str() {
"postgres" | "pg" | "pgsql" => {
use container::db::postgresql::PostgreSQLContainer;
println!("Using PostgreSQL database");
PostgreSQLContainer::new(None)
.await
.get_db_container_config_info()
.await
}
"sqlite" | "sql" => {
println!("Using SQLite database");
use container::db::sqlite::SQLiteContainer;
SQLiteContainer::new(None)
.await
.get_db_container_config_info()
.await
}
other => {
eprintln!("Unknown db_type: {}. Use 'postgres' or 'sqlite'", other);
std::process::exit(1);
}
};
println!("Database configuration obtained.");
let config = Config {
database: db_config,
};
println!("Starting container...");
start_attached(&config).await;
println!("Container stopped. Exiting...");
}

View File

@@ -0,0 +1,21 @@
use std::sync::Arc;
use testcontainers::{ContainerAsync, GenericImage};
pub trait WithContainer {
fn container(&self) -> &Arc<ContainerAsync<GenericImage>>;
}
pub trait WithoutContainer {
fn on_delete(&self);
}
#[derive(Clone)]
pub enum ConfigInfoType<T, U>
where
T: WithContainer,
U: WithoutContainer,
{
Containerized(T),
PreExisting(U),
}

114
apps/container/src/util.rs Normal file
View File

@@ -0,0 +1,114 @@
use path_clean::PathClean;
use std::path::{Path, PathBuf};
use tokio::signal::unix::{SignalKind, signal};
use crate::{
API_CONFIG_PATH, DB_CONFIG_PATH,
db::DBConfigInfoType,
env::{self, EnvFile},
types::{ConfigInfoType, WithContainer, WithoutContainer},
};
// relative to the current working directory
pub fn to_absolute_path(path: &str) -> PathBuf {
if Path::new(path).is_absolute() {
return PathBuf::from(path);
}
std::env::current_dir()
.map(|cwd| cwd.join(path))
.unwrap_or_else(|_| PathBuf::from(path))
.clean()
}
pub fn write_env_files(db_config: &DBConfigInfoType) {
let api_config_path_absolute = to_absolute_path(API_CONFIG_PATH);
let db_config_path_absolute = to_absolute_path(DB_CONFIG_PATH);
let (db_type, db_url) = match db_config {
DBConfigInfoType::Containerized(config) => (config.db_type.clone(), config.url.clone()),
DBConfigInfoType::PreExisting(config) => (config.db_type.clone(), config.url.clone()),
};
let api_env_file = EnvFile {
file_type: env::EnvFileType::Yaml,
db_type: db_type,
db_url: db_url,
};
let mut db_env_file = api_env_file.clone();
db_env_file.file_type = env::EnvFileType::DotEnv;
api_env_file.write(&api_config_path_absolute);
db_env_file.write(&db_config_path_absolute);
}
pub async fn stop_container(
config: &ConfigInfoType<impl WithContainer, impl WithoutContainer>,
config_name: String,
) {
match config {
ConfigInfoType::Containerized(container_info) => {
let container = container_info.container();
if let Err(e) = container.stop().await {
eprintln!(
"Failed to stop container: {}. With error: {}",
config_name, e
);
} else {
println!("Container {} stopped successfully.", config_name);
}
}
ConfigInfoType::PreExisting(pre_existing_info) => {
pre_existing_info.on_delete();
println!(
"Pre-existing resource for {} cleaned up successfully.",
config_name
);
}
}
}
pub fn remove_file_if_exists(path: &str) {
let path = std::path::Path::new(path);
if path.exists() {
if let Err(e) = std::fs::remove_file(path) {
eprintln!("Failed to remove file {}: {}", path.display(), e);
} else {
println!("Removed existing file: {}", path.display());
}
}
}
pub async fn await_termination_signal() {
tokio::select! {
_ = tokio::signal::ctrl_c() => {
println!("\nReceived Ctrl+C, stopping container...");
}
_ = async {
#[cfg(unix)]
{
let mut sigterm = signal(SignalKind::terminate()).expect("Failed to register SIGTERM handler");
sigterm.recv().await;
println!("\nReceived SIGTERM, stopping container...");
}
#[cfg(not(unix))]
{
// On non-Unix systems, just wait indefinitely
std::future::pending::<()>().await;
}
} => {}
_ = async {
#[cfg(unix)]
{
let mut sigquit = signal(SignalKind::quit()).expect("Failed to register SIGQUIT handler");
sigquit.recv().await;
println!("\nReceived SIGQUIT, stopping container...");
}
#[cfg(not(unix))]
{
// On non-Unix systems, just wait indefinitely
std::future::pending::<()>().await;
}
} => {}
}
}

7
justfile Normal file
View File

@@ -0,0 +1,7 @@
simulate *args:
cd src/container && \
if [ -n "{{args}}" ]; then \
cargo run --bin container-simulate -- --db-type={{args}}; \
else \
cargo run --bin container-simulate; \
fi