Merge branch 'main' into dev

# Conflicts:
#	docker-compose.yml
This commit is contained in:
charles-gauthereau
2026-03-16 14:36:05 +01:00
20 changed files with 314 additions and 22 deletions
+1 -1
View File
@@ -27,5 +27,5 @@ keywords:
- self-hosted
- portabase
license: Apache-2.0
version: 1.5.0
version: 1.5.1
date-released: '2026-02-24'
Generated
+1 -1
View File
@@ -3038,7 +3038,7 @@ dependencies = [
[[package]]
name = "portabase-agent"
version = "1.5.0"
version = "1.5.1"
dependencies = [
"aes",
"aes-gcm",
+3 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "portabase-agent"
version = "1.5.0"
version = "1.5.1"
edition = "2024"
[dependencies]
@@ -30,7 +30,7 @@ tokio-postgres = "0.7.15"
futures = "0.3.31"
tracing-appender = "0.2.4"
time = { version = "0.3.44", features = ["macros"] }
mongodb = "3.5.0"
mongodb = "3.5.1"
rand = "0.9.2"
bytes = "1.11.0"
async-stream = "0.3.6"
@@ -49,7 +49,7 @@ tokio-stream = "0.1.18"
aes = "0.9.0-rc.4"
typenum = "1.19.0"
testcontainers = "0.27.1"
testcontainers-modules = { version = "0.15.0", features = ["postgres", "redis"] }
testcontainers-modules = { version = "0.15.0", features = ["postgres", "redis", "valkey"] }
postgres = "0.19.12"
url = "2.5.8"
+2
View File
@@ -19,6 +19,8 @@
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-336791?logo=postgresql&logoColor=white)](https://www.postgresql.org/)
[![MySQL](https://img.shields.io/badge/MySQL-4479A1?logo=mysql&logoColor=white)](https://www.mysql.com/)
[![SQLite](https://img.shields.io/badge/-SQLite-blue?logo=sqlite&logoColor=white)](https://sqlite.org/)
[![Redis](https://img.shields.io/badge/Redis-DC382D?style=flat&logo=Redis&logoColor=white)](https://redis.io/)
[![Valkey](https://img.shields.io/badge/Valkey-6284fc?style=flat&logo=Valkey&logoColor=white)](https://valkey.io/)
[![MariaDB](https://img.shields.io/badge/MariaDB-003545?logo=mariadb&logoColor=white)](https://mariadb.org/)
[![MongoDB](https://img.shields.io/badge/-MongoDB-13aa52?logo=mongodb&logoColor=white)](https://www.mongodb.com/)
[![Self Hosted](https://img.shields.io/badge/self--hosted-yes-brightgreen)](https://github.com/Portabase/portabase)
+17 -1
View File
@@ -60,11 +60,27 @@
{
"name": "Test database 9 - Redis Auth",
"type": "redis",
"password": "m6o2L0Ukw3Og5RJUKh",
"password": "supersecurepassword",
"port": 6379,
"username": "default",
"host": "db-redis-auth",
"generated_id": "16678160-ff7e-4c97-8c83-0adeff214681"
},
{
"name": "Test database 10 - Valkey",
"type": "valkey",
"port": 6379,
"host": "db-valkey",
"generated_id": "16678560-ff7e-4c97-8c83-0adeff214681"
},
{
"name": "Test database 11 - Valkey Auth",
"type": "valkey",
"password": "supersecurepassword",
"port": 6379,
"username": "default",
"host": "db-valkey-auth",
"generated_id": "16678561-ff7e-4c97-8c83-0adeff214681"
}
]
}
+29 -4
View File
@@ -18,7 +18,7 @@ services:
APP_ENV: development
LOG: debug
TZ: "Europe/Paris"
EDGE_KEY: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiZWE5OWE5ZjMtNDhkYy00MjMyLTkzMTAtNTc5YWVkY2IyZjhmIiwibWFzdGVyS2V5QjY0IjoiQlhWM1hvbEM2NTZTVjdkTmdjV1BHUWxrKytycExJNmxHRGk3Q1BCNWllbz0ifQ=="
EDGE_KEY: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiMmVkZWFlYjctNWYwNi00MjI1LWIzNWItOGYyM2MwOTMyMTA2IiwibWFzdGVyS2V5QjY0IjoiMUh0djdtWCtYVkJxL0IzUEV2WDlZZjlQeUdVZW5oRHlXemo5THRqNW90WT0ifQ=="
#POOLING: 1
#DATABASES_CONFIG_FILE: "config.toml"
extra_hosts:
@@ -122,8 +122,32 @@ services:
volumes:
- redis-data-auth:/data
environment:
- REDIS_PASSWORD=m6o2L0Ukw3Og5RJUKh
command: [ "redis-server", "--requirepass", "m6o2L0Ukw3Og5RJUKh", "--appendonly", "yes" ]
- REDIS_PASSWORD=supersecurepassword
command: [ "redis-server", "--requirepass", "supersecurepassword", "--appendonly", "yes" ]
networks:
- portabase
db-valkey:
image: valkey/valkey
container_name: db-valkey
environment:
- ALLOW_EMPTY_PASSWORD=yes
ports:
- '6381:6379'
volumes:
- valkey-data:/data
networks:
- portabase
db-valkey-auth:
image: valkey/valkey
container_name: db-valkey-auth
command: >
--requirepass "supersecurepassword"
ports:
- '6382:6379'
volumes:
- valkey-data-auth:/data
networks:
- portabase
@@ -131,7 +155,6 @@ volumes:
cargo-registry:
cargo-git:
# cargo-target:
postgres-data:
# mariadb-data:
# mongodb-data:
@@ -139,6 +162,8 @@ volumes:
# sqlite-data:
redis-data:
redis-data-auth:
valkey-data:
valkey-data-auth:
networks:
portabase:
+2
View File
@@ -17,6 +17,7 @@ RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \
mariadb-client \
sqlite3 \
redis-tools \
valkey \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
@@ -96,6 +97,7 @@ RUN apt-get update && apt-get install -y \
mariadb-client \
sqlite3 \
redis-tools \
valkey \
&& rm -rf /var/lib/apt/lists/*
+1 -1
View File
@@ -35,5 +35,5 @@ check_docker
check_network
echo "Starting docker-compose..."
docker-compose -f ./docker-compose.yml up
docker compose -f ./docker-compose.yml up
echo "Docker-compose started successfully."
+10 -3
View File
@@ -34,16 +34,23 @@ if [ -n "$TZ" ]; then
fi
fi
REDIS_PORT=65515
echo "[entrypoint] APP_ENV=$APP_ENV"
echo "[entrypoint] Starting Redis..."
redis-server --daemonize yes
redis-server --port $REDIS_PORT --daemonize yes
echo "[entrypoint] Waiting for Redis to be ready..."
until redis-cli ping >/dev/null 2>&1; do
MAX_RETRIES=20
COUNT=0
until redis-cli -h localhost -p "$REDIS_PORT" ping >/dev/null 2>&1 ; do
COUNT=$((COUNT+1))
if [ $COUNT -ge $MAX_RETRIES ]; then
echo "[ERROR] Redis did not start after $MAX_RETRIES attempts"
exit 1
fi
echo "[entrypoint] Redis not ready, sleeping 1s..."
sleep 1
done
echo "[entrypoint] Redis is ready"
+2 -1
View File
@@ -58,7 +58,7 @@ if [[ "$OS_TYPE" == "Linux" ]]; then
if command -v apt >/dev/null 2>&1; then
echo "Linux detected with apt. Installing prerequisites..."
sudo apt update
sudo apt install -y wget gnupg lsb-release redis-tools
sudo apt install -y wget gnupg lsb-release redis-tools valkey
install_pg_binaries
else
echo "Unsupported Linux distribution. Only apt-based distros are supported."
@@ -69,6 +69,7 @@ elif [[ "$OS_TYPE" == "Darwin" ]]; then
if command -v brew >/dev/null 2>&1; then
echo "macOS detected. Installing prerequisites..."
brew install redis
brew install valkey
sudo mkdir -p "$POSTGRES_BASE"
sudo chown -R "$(whoami)" "$POSTGRES_BASE"
+4
View File
@@ -8,6 +8,7 @@ use crate::services::config::{DatabaseConfig, DbType};
use anyhow::Result;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::domain::valkey::database::ValkeyDatabase;
#[async_trait::async_trait]
pub trait Database: Send + Sync {
@@ -31,6 +32,7 @@ impl DatabaseFactory {
DbType::MongoDB => Arc::new(MongoDatabase::new(cfg)),
DbType::Sqlite => Arc::new(SqliteDatabase::new(cfg)),
DbType::Redis => Arc::new(RedisDatabase::new(cfg)),
DbType::Valkey => Arc::new(ValkeyDatabase::new(cfg))
}
}
@@ -45,6 +47,8 @@ impl DatabaseFactory {
DbType::MongoDB => Arc::new(MongoDatabase::new(cfg)),
DbType::Sqlite => Arc::new(SqliteDatabase::new(cfg)),
DbType::Redis => Arc::new(RedisDatabase::new(cfg)),
DbType::Valkey => Arc::new(ValkeyDatabase::new(cfg))
}
}
}
+1
View File
@@ -4,4 +4,5 @@ pub mod mysql;
mod mongodb;
mod sqlite;
mod redis;
mod valkey;
+65
View File
@@ -0,0 +1,65 @@
use crate::services::config::DatabaseConfig;
use anyhow::{Context, Result};
use std::path::PathBuf;
use std::process::Command;
use tracing::{debug, error, info};
pub async fn run(
cfg: DatabaseConfig,
backup_dir: PathBuf,
file_extension: &'static str,
) -> Result<PathBuf> {
tokio::task::spawn_blocking(move || -> Result<PathBuf> {
debug!("Starting Valkey backup for database {}", cfg.name);
let file_path = backup_dir.join(format!("{}{}", cfg.generated_id, file_extension));
let mut cmd = Command::new("valkey-cli");
cmd.arg("-h")
.arg(&cfg.host)
.arg("-p")
.arg(cfg.port.to_string());
if !cfg.username.is_empty() {
cmd.arg("--user").arg(&cfg.username);
}
if !cfg.password.is_empty() {
cmd.arg("-a").arg(&cfg.password);
}
cmd.arg("--rdb").arg(&file_path);
debug!("Command Backup: {:?}", cmd);
let output = cmd.output().context("Valkey backup command failed")?;
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
if !output.status.success() {
if stderr.contains("NOAUTH") {
error!(
"Valkey backup failed for {}: Authentication required (NOAUTH)",
cfg.name
);
anyhow::bail!(
"Valkey backup failed for {}: Authentication required",
cfg.name
);
} else {
error!("Valkey backup failed for {}: {}", cfg.name, stderr);
anyhow::bail!("Valkey backup failed for {}: {}", cfg.name, stderr);
}
}
info!(
"Valkey backup completed for {}. Output: {}",
cfg.name, stdout
);
Ok(file_path)
})
.await?
}
+45
View File
@@ -0,0 +1,45 @@
use anyhow::{Result, bail};
use async_trait::async_trait;
use std::path::{Path, PathBuf};
use crate::domain::factory::Database;
use crate::domain::valkey::{backup, ping};
use crate::services::config::DatabaseConfig;
use crate::utils::locks::{DbOpLock, FileLock};
pub struct ValkeyDatabase {
cfg: DatabaseConfig,
}
impl ValkeyDatabase {
pub fn new(cfg: DatabaseConfig) -> Self {
Self { cfg }
}
}
#[async_trait]
impl Database for ValkeyDatabase {
fn file_extension(&self) -> &'static str {
".rdb"
}
async fn ping(&self) -> Result<bool> {
ping::run(self.cfg.clone()).await
}
async fn backup(&self, dir: &Path, is_test: Option<bool>) -> Result<PathBuf> {
let test_mode = is_test.unwrap_or(false);
if !test_mode {
FileLock::acquire(&self.cfg.generated_id, DbOpLock::Backup.as_str()).await?;
}
let res = backup::run(self.cfg.clone(), dir.to_path_buf(), self.file_extension()).await;
if !test_mode {
FileLock::release(&self.cfg.generated_id).await?;
}
res
}
async fn restore(&self, _file: &Path, _is_test: Option<bool>) -> Result<()> {
bail!("Restore not supported for Valkey databases")
}
}
+3
View File
@@ -0,0 +1,3 @@
mod backup;
pub mod database;
mod ping;
+55
View File
@@ -0,0 +1,55 @@
use tracing::{debug, info};
use crate::services::config::DatabaseConfig;
use tokio::process::Command;
use tokio::time::{timeout, Duration};
use anyhow::{Result, Context};
pub async fn run(cfg: DatabaseConfig) -> Result<bool> {
let mut cmd = Command::new("valkey-cli");
cmd.arg("-h")
.arg(&cfg.host)
.arg("-p")
.arg(cfg.port.to_string());
if !cfg.username.is_empty() {
cmd.arg("--user").arg(&cfg.username);
}
if !cfg.password.is_empty() {
cmd.arg("-a").arg(&cfg.password);
}
cmd.arg("PING");
debug!("Command Ping: {:?}", cmd);
let result = timeout(Duration::from_secs(10), cmd.output()).await;
match result {
Ok(output) => {
let output = output.context("Failed to execute redis-cli")?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
info!("Redis stdout: {}", stdout);
info!("Redis stderr: {}", stderr);
if stderr.contains("NOAUTH") {
info!("Redis authentication failed (NOAUTH required)");
return Ok(false);
}
if !output.status.success() {
info!("Redis command failed with status: {:?}", output.status);
return Ok(false);
}
Ok(stdout.contains("PONG"))
}
Err(_) => {
info!("Timeout connecting to Redis at {}:{}", cfg.host, cfg.port);
Ok(false)
}
}
}
+6 -5
View File
@@ -19,8 +19,8 @@ pub enum DbType {
Postgresql,
MongoDB,
Sqlite,
Redis
// Add other DB types if needed
Redis,
Valkey
}
impl DbType {
@@ -32,6 +32,7 @@ impl DbType {
DbType::MongoDB => "mongodb",
DbType::Sqlite => "sqlite",
DbType::Redis => "redis",
DbType::Valkey => "valkey",
}
}
}
@@ -164,17 +165,17 @@ impl ConfigService {
};
let host = match db.db_type {
DbType::Postgresql | DbType::Mysql | DbType::Mariadb | DbType::MongoDB | DbType::Redis => required(&db.host, &db.name, "host")?,
DbType::Postgresql | DbType::Mysql | DbType::Mariadb | DbType::MongoDB | DbType::Redis | DbType::Valkey => required(&db.host, &db.name, "host")?,
DbType::Sqlite => optional(&db.host),
};
let port = match db.db_type {
DbType::Postgresql | DbType::Mysql | DbType::Mariadb | DbType::MongoDB | DbType::Redis => required(&db.port, &db.name, "port")?,
DbType::Postgresql | DbType::Mysql | DbType::Mariadb | DbType::MongoDB | DbType::Redis | DbType::Valkey => required(&db.port, &db.name, "port")?,
DbType::Sqlite => db.port.unwrap_or(0),
};
let database_name = match db.db_type {
DbType::Sqlite | DbType::Redis => optional(&db.database),
DbType::Sqlite | DbType::Redis | DbType::Valkey => optional(&db.database),
_ => required(&db.database, &db.name, "database")?
};
+1 -1
View File
@@ -31,7 +31,7 @@ impl Settings {
app_version: env!("CARGO_PKG_VERSION").to_string(),
app_env: env::var("APP_ENV").unwrap_or_else(|_| "development".into()),
redis_url: env::var("CELERY_BROKER_URL")
.unwrap_or_else(|_| "redis://localhost:6379/".into()),
.unwrap_or_else(|_| "redis://localhost:65515/".into()),
edge_key: env::var("EDGE_KEY").unwrap_or_default(),
databases_config_file: env::var("DATABASES_CONFIG_FILE")
.unwrap_or_else(|_| "config.json".into()),
+2 -1
View File
@@ -1,2 +1,3 @@
mod postgres;
mod redis;
mod redis;
mod valkey;
+64
View File
@@ -0,0 +1,64 @@
use tempfile::TempDir;
use testcontainers::runners::AsyncRunner;
use testcontainers::ContainerAsync;
use testcontainers_modules::valkey::{Valkey};
use url::Host;
use crate::domain::factory::DatabaseFactory;
use crate::services::config::{DatabaseConfig, DbType};
use crate::tests::init_tracing_for_test;
async fn create_config() -> (ContainerAsync<Valkey>, DatabaseConfig) {
let container = Valkey::default().start().await.unwrap();
let host = container
.get_host()
.await
.unwrap_or(Host::parse("127.0.0.1").unwrap());
let port = container
.get_host_port_ipv4(6379)
.await
.unwrap_or(6379);
let config = DatabaseConfig {
name: "Test Valkey".to_string(),
database: "valkey".to_string(),
username: "".to_string(),
password: "".to_string(),
db_type: DbType::Valkey,
port,
host: host.to_string(),
generated_id: "40875485-e3d2-4dfe-a26b-2a347ecc64fd".to_string(),
path: "".to_string(),
};
(container, config)
}
#[tokio::test]
async fn valkey_ping_test() {
init_tracing_for_test();
let (_container, config) = create_config().await;
let db = DatabaseFactory::create_for_backup(config.clone()).await;
let reachable = db.ping().await.unwrap_or(false);
assert!(reachable);
}
#[tokio::test]
async fn valkey_backup_test() {
init_tracing_for_test();
let (_container, config) = create_config().await;
let temp_dir = TempDir::new().unwrap();
let backup_path = temp_dir.path();
let db = DatabaseFactory::create_for_backup(config.clone()).await;
let file_path = db.backup(backup_path, Some(true)).await.unwrap();
assert!(file_path.is_file());
}