Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ services:
APP_ENV: development
LOG: debug
TZ: "Europe/Paris"
EDGE_KEY: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiNzM0NjU3Y2YtMGQzYy00Y2UwLTkyODQtZDJmOGYyMjI2MzgzIiwibWFzdGVyS2V5QjY0IjoiMUh0djdtWCtYVkJxL0IzUEV2WDlZZjlQeUdVZW5oRHlXemo5THRqNW90WT0ifQ=="
EDGE_KEY: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiOTFlYmY4MmYtY2I4OC00N2M3LWE5MWUtMDUyMmU2NzNjZjE4IiwibWFzdGVyS2V5QjY0IjoiQlhWM1hvbEM2NTZTVjdkTmdjV1BHUWxrKytycExJNmxHRGk3Q1BCNWllbz0ifQ=="
#CHUNK_SIZE_MB: "1"
#POOLING: 1
#DATABASES_CONFIG_FILE: "config.toml"
Expand All @@ -35,8 +35,6 @@ volumes:
databases_sqlite-data:
external: true



networks:
portabase:
name: portabase_network
Expand Down
3 changes: 2 additions & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ seed-firebird:
docker exec -i db-firebird isql -user alice -password fake_password /var/lib/firebird/data/mirror.fdb < ./scripts/firebird/seed.sql

echo "Verifying Firebird tables..."
docker exec -i db-firebird isql -user alice -password fake_password /var/lib/firebird/data/mirror.fdb -sql "SHOW TABLES;"
echo "SELECT RDB\$RELATION_NAME FROM RDB\$RELATIONS WHERE RDB\$SYSTEM_FLAG = 0 AND RDB\$VIEW_BLR IS NULL;" \
| docker exec -i db-firebird isql -user alice -password fake_password /var/lib/firebird/data/mirror.fdb

seed-all:
just seed-mongo
Expand Down
7 changes: 4 additions & 3 deletions src/domain/mariadb/backup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ pub async fn run(
let mariadb_dump = select_mariadb_path(&version).join("mariadb-dump");
info!("Mariadb dump found: {}", mariadb_dump.display());


let output = Command::new("mariadb-dump")
Comment on lines 33 to 37
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

mariadb_dump is resolved by version but never used — falls back to $PATH.

Lines 33–34 compute and log a version-specific path, then line 37 spawns the bare string "mariadb-dump", which makes select_mariadb_path effectively dead code and breaks the per-version targeting. If the host has multiple mariadb-dump binaries (or none on $PATH), behavior diverges from what the log line claims.

🔧 Proposed fix
-        let output = Command::new("mariadb-dump")
+        let output = Command::new(&mariadb_dump)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let mariadb_dump = select_mariadb_path(&version).join("mariadb-dump");
info!("Mariadb dump found: {}", mariadb_dump.display());
let output = Command::new("mariadb-dump")
let mariadb_dump = select_mariadb_path(&version).join("mariadb-dump");
info!("Mariadb dump found: {}", mariadb_dump.display());
let output = Command::new(&mariadb_dump)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/domain/mariadb/backup.rs` around lines 33 - 37, The code computes a
version-specific path into the mariadb_dump variable via
select_mariadb_path(&version) but then calls Command::new("mariadb-dump"),
ignoring that path; replace the hardcoded Command::new("mariadb-dump") with a
call that uses the computed mariadb_dump (e.g., pass mariadb_dump or
mariadb_dump.as_os_str() / mariadb_dump.as_path() to Command::new) so the
spawned process uses the version-specific binary selected by select_mariadb_path
and matches the logged path.

.arg("--host").arg(&cfg.host)
.arg("--port").arg(cfg.port.to_string())
Expand All @@ -43,18 +44,18 @@ pub async fn run(
.arg("--single-transaction")
.arg("--quick")
.arg("--skip-lock-tables")
.arg("--add-drop-database")
.arg("--databases").arg(&cfg.database)
.arg("--no-create-db")
.arg("--skip-add-drop-table")
.arg("--compress")
.arg("--max-allowed-packet=512M")
.arg("--net-buffer-length=16K")
.arg("--default-character-set=utf8mb4")
.arg(&cfg.database)
.arg("-r").arg(&file_path)
.envs(env)
.output()
.with_context(|| format!("Failed to run mariadb-dump for {}", cfg.name))?;


if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Mariadb backup failed for {}: {}", cfg.name, stderr);
Expand Down
7 changes: 4 additions & 3 deletions src/domain/mariadb/restore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ pub async fn run(cfg: DatabaseConfig, restore_file: PathBuf) -> Result<()> {
.with_context(|| format!("Failed to open restore file {}", restore_file.display()))?;
file.read_to_string(&mut sql_content)
.with_context(|| format!("Failed to read restore file {}", restore_file.display()))?;

let drop_create_cmd = format!(
"DROP DATABASE IF EXISTS {0}; CREATE DATABASE {0};",
"DROP DATABASE IF EXISTS `{0}`; CREATE DATABASE `{0}`;",
cfg.database
);

Expand Down Expand Up @@ -47,6 +47,7 @@ pub async fn run(cfg: DatabaseConfig, restore_file: PathBuf) -> Result<()> {
.arg(cfg.port.to_string())
.arg("--user")
.arg(&cfg.username)
.arg("--database")
.arg(&cfg.database)
.env("MYSQL_PWD", &cfg.password)
.stdin(std::process::Stdio::piped())
Expand Down Expand Up @@ -77,4 +78,4 @@ pub async fn run(cfg: DatabaseConfig, restore_file: PathBuf) -> Result<()> {
handle.await??;

Ok(())
}
}
1 change: 0 additions & 1 deletion src/domain/mongodb/backup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ pub async fn run(

let file_path = backup_dir.join(format!("{}{}", cfg.generated_id, file_extension));
let mongodump = select_mongo_path().join("mongodump");
info!("{:?}", mongodump);
let uri = get_mongo_uri(cfg.clone())?;

let output = Command::new(mongodump)
Expand Down
15 changes: 15 additions & 0 deletions src/domain/mongodb/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,18 @@ pub fn get_mongo_uri(cfg: DatabaseConfig) -> Result<String> {
))
}
}


pub fn extract_db_name(dry_output: &str) -> Option<String> {
let mut dbs = std::collections::HashSet::new();
for line in dry_output.lines() {
if let Some(pos) = line.find("archive prelude ") {
let rest = &line[pos + "archive prelude ".len()..];
if let Some(dot) = rest.find('.') {
let db = &rest[..dot];
dbs.insert(db.to_string());
}
}
}
dbs.into_iter().next()
}
Comment on lines +34 to +45
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fail closed when multiple source DBs are detected.

HashSet::into_iter().next() at Line 44 is nondeterministic for multi-DB archives, so restore target mapping can randomly pick the wrong source DB.

💡 Proposed fix
 pub fn extract_db_name(dry_output: &str) -> Option<String> {
-    let mut dbs = std::collections::HashSet::new();
+    let mut dbs = std::collections::BTreeSet::new();
     for line in dry_output.lines() {
         if let Some(pos) = line.find("archive prelude ") {
             let rest = &line[pos + "archive prelude ".len()..];
             if let Some(dot) = rest.find('.') {
                 let db = &rest[..dot];
-                dbs.insert(db.to_string());
+                if !db.is_empty() {
+                    dbs.insert(db.to_string());
+                }
             }
         }
     }
-    dbs.into_iter().next()
+    if dbs.len() == 1 {
+        dbs.into_iter().next()
+    } else {
+        None
+    }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/domain/mongodb/connection.rs` around lines 34 - 45, The current logic
collects candidate DB names into the HashSet `dbs` and then uses
`dbs.into_iter().next()` which is nondeterministic when multiple source DBs
exist; change this to explicitly check `dbs.len()` and fail closed if it is not
exactly 1 (e.g., return None or an Err) so a multi-DB archive doesn't randomly
pick a DB; replace the final `dbs.into_iter().next()` with a branch that returns
the single DB when `dbs.len() == 1` and returns an error/None when
`dbs.is_empty()` or `dbs.len() > 1`, referencing the `dbs` variable and the
parsing loop that finds `"archive prelude "` to locate the code.

29 changes: 26 additions & 3 deletions src/domain/mongodb/restore.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::domain::mongodb::connection::{get_mongo_uri, select_mongo_path};
use crate::domain::mongodb::connection::{extract_db_name, get_mongo_uri, select_mongo_path};
use crate::services::config::DatabaseConfig;
use anyhow::{Context, Result};
use std::path::PathBuf;
Expand All @@ -12,18 +12,41 @@ pub async fn run(cfg: DatabaseConfig, restore_file: PathBuf) -> Result<()> {
let mongorestore = select_mongo_path().join("mongorestore");
let uri = get_mongo_uri(cfg.clone())?;

let output = Command::new(mongorestore)
let dry_run = Command::new(&mongorestore)
.arg(format!(
"--uri={}",
format!(
"mongodb://{}:{}@{}:{}/?authSource=admin",
cfg.username, cfg.password, cfg.host, cfg.port
)
))
Comment on lines +15 to +22
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

For mongorestore, is URI mongodb://:@host:port/?authSource=adminvalid when authentication is disabled, or should anonymous connections usemongodb://host:port/?

💡 Result:

When MongoDB authentication is truly disabled, you should use an anonymous connection string that does not include username/password material. So, use: mongodb://host:port/ The URI you proposed, mongodb://:@host:port/?authSource=admin, is not the right form for an anonymous (no-credentials) connection. In MongoDB connection strings, the "username:password@" portion is optional authentication credentials; the client only attempts authentication when credentials are provided. The authSource option is defined as the database associated with user credentials, and MongoDB ignores authSource when no username is provided. Given that, providing empty credentials while also specifying authSource is at best unnecessary and may be rejected or mis-parsed by drivers. Recommended for mongorestore (no auth): mongorestore --uri "mongodb://host:port/" ./dump If you want to restore a specific database, include it in the path (without auth options), e.g.: mongorestore --uri "mongodb://host:port/testdb" ./dump

Citations:


🏁 Script executed:

cat -n src/domain/mongodb/restore.rs | head -50

Repository: Portabase/agent

Length of output: 2471


🏁 Script executed:

cat -n src/domain/mongodb/connection.rs

Repository: Portabase/agent

Length of output: 1832


Use consistent URI construction for no-auth MongoDB configs in dry-run command.

At lines 15–22, the dry-run command always injects username:password and authSource=admin into the URI. For no-auth configurations (empty username or password), this produces an invalid URI like mongodb://:@host:port/?authSource=admin. Meanwhile, the actual restore command at line 36 uses get_mongo_uri(), which correctly handles no-auth by returning mongodb://host:port/database. This inconsistency causes dry-run to fail on valid no-auth setups while the actual restore would succeed.

Apply the same conditional logic from get_mongo_uri() to construct the dry-run URI:

Proposed fix
-        let dry_run = Command::new(&mongorestore)
+        let dry_uri = if cfg.username.is_empty() || cfg.password.is_empty() {
+            format!("mongodb://{}:{}/", cfg.host, cfg.port)
+        } else {
+            format!(
+                "mongodb://{}:{}@{}:{}/?authSource=admin",
+                cfg.username, cfg.password, cfg.host, cfg.port
+            )
+        };
+
+        let dry_run = Command::new(&mongorestore)
             .arg(format!(
                 "--uri={}",
-                format!(
-                    "mongodb://{}:{}@{}:{}/?authSource=admin",
-                    cfg.username, cfg.password, cfg.host, cfg.port
-                )
+                dry_uri
             ))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let dry_run = Command::new(&mongorestore)
.arg(format!(
"--uri={}",
format!(
"mongodb://{}:{}@{}:{}/?authSource=admin",
cfg.username, cfg.password, cfg.host, cfg.port
)
))
let dry_uri = if cfg.username.is_empty() || cfg.password.is_empty() {
format!("mongodb://{}:{}/", cfg.host, cfg.port)
} else {
format!(
"mongodb://{}:{}@{}:{}/?authSource=admin",
cfg.username, cfg.password, cfg.host, cfg.port
)
};
let dry_run = Command::new(&mongorestore)
.arg(format!(
"--uri={}",
dry_uri
))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/domain/mongodb/restore.rs` around lines 15 - 22, The dry-run Command
construction currently in the block that defines dry_run and builds a
"--uri=..." string should use the same logic as get_mongo_uri() to avoid
inserting empty "username:password" and "authSource=admin" for no-auth configs;
update the dry-run URI construction (the Command::new(&mongorestore) invocation
that sets .arg(format!("--uri={}", ...))) to call or reimplement the
get_mongo_uri() logic (checking cfg.username/cfg.password and conditionally
including credentials and authSource) so the dry-run URI matches the actual
restore URI returned by get_mongo_uri().

.arg(format!("--archive={}", restore_file.display()))
.arg("--gzip")
.arg("--dryRun")
.arg("--verbose")
.output()?;

let dry_output = String::from_utf8_lossy(&dry_run.stderr);
let source_db = extract_db_name(&dry_output)
.context("Could not detect source database name from archive")?;
Comment on lines +29 to +31
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In mongorestore --dryRun --verbose mode, are archive prelude details guaranteed on stderr only, and should callers treat non-zero exit as authoritative failure before parsing output?

💡 Result:

Non-zero exit status: treat it as authoritative failure. Don’t base correctness on whether archive “prelude” details appear on stderr vs stdout. stderr vs stdout for archive prelude details - MongoDB’s docs state that the database tools (including mongorestore) print logs to stderr. - The documentation for --dryRun + --verbose describes verbosity affecting “internal reporting returned on standard output or in log files” (i.e., you should not assume anything about where specific messages land). - In the mongorestore code, archive prelude metadata is parsed/used internally (and failures in parsing/reading return an error), rather than being something a caller should infer from stderr-only output. Should callers parse output and ignore exit code? - No: callers should key off mongorestore’s process exit status. If mongorestore encounters errors such as inability to parse/validate options or read archive/prelude metadata, the tool returns an error result and exits non-zero. - In general, callers should not treat “message seen on stderr” as the only signal. Use the exit code to decide whether it is safe to parse any summary output. Practical guidance 1) Run mongorestore with --dryRun --verbose and capture both streams if you need logs. 2) If the command’s exit code is non-zero, treat the run as failed and do not trust/parse success summaries that may still have been printed. 3) Only parse success/summary output when exit code is 0. Grounding notes: This applies to archive prelude handling because prelude metadata is not merely informational; mongorestore reads it as part of establishing the archive/dump format and may abort the restore when it can’t be processed, independent of whether any particular detail was emitted on stderr.

Citations:


🏁 Script executed:

cat -n src/domain/mongodb/restore.rs | sed -n '1,50p'

Repository: Portabase/agent

Length of output: 2471


🏁 Script executed:

wc -l src/domain/mongodb/restore.rs

Repository: Portabase/agent

Length of output: 92


🏁 Script executed:

cat -n src/domain/mongodb/restore.rs | sed -n '15,45p'

Repository: Portabase/agent

Length of output: 1501


Check dry-run exit status before parsing stderr.

The dry-run command exits at line 27 without verifying success. If mongorestore --dryRun fails due to authentication, network, or archive issues, line 30 currently masks the real error by reporting a parse failure instead. Add a status check immediately after line 27:

Fix
         let dry_output = String::from_utf8_lossy(&dry_run.stderr);
+        if !dry_run.status.success() {
+            anyhow::bail!("mongorestore dry-run failed for {}: {}", cfg.name, dry_output);
+        }
         let source_db = extract_db_name(&dry_output)
             .context("Could not detect source database name from archive")?;

The actual restore command at line 46 correctly checks output.status.success() — apply the same pattern here.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let dry_output = String::from_utf8_lossy(&dry_run.stderr);
let source_db = extract_db_name(&dry_output)
.context("Could not detect source database name from archive")?;
let dry_output = String::from_utf8_lossy(&dry_run.stderr);
if !dry_run.status.success() {
anyhow::bail!("mongorestore dry-run failed for {}: {}", cfg.name, dry_output);
}
let source_db = extract_db_name(&dry_output)
.context("Could not detect source database name from archive")?;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/domain/mongodb/restore.rs` around lines 29 - 31, The dry-run subprocess
result stored in dry_run must have its exit status verified before attempting to
parse stderr; after running the dry-run (dry_run variable) check
dry_run.status.success() and return an error (with context like "mongorestore
--dryRun failed") if it failed, so that failures (auth/network/archive) are
reported instead of being masked by extract_db_name(&dry_output) parsing errors;
update the logic around the dry_run handling to mirror the later restore
output.status.success() check and only call extract_db_name when the dry_run
succeeded.


info!("Detected source database in archive: {}", source_db);

let output = Command::new(&mongorestore)
.arg(format!("--uri={}", uri))
.arg(format!("--archive={}", restore_file.display()))
.arg("--gzip")
.arg("--drop")
.arg(format!("--nsInclude={}.*", source_db))
.arg(format!("--nsFrom={}.*", source_db))
.arg(format!("--nsTo={}.*", cfg.database))
.output()
.with_context(|| format!("Failed to run mongorestore for {}", cfg.name))?;

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
error!("MongoDB restore failed for {}: {}", cfg.name, stderr);
anyhow::bail!("MongoDB restore failed for : {}", cfg.name);
anyhow::bail!("MongoDB restore failed for: {}", cfg.name);
}

info!("MongoDB restore completed for {}", cfg.name);
Expand Down
16 changes: 7 additions & 9 deletions src/domain/mysql/backup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,25 +30,23 @@ pub async fn run(

let file_path = backup_dir.join(format!("{}{}", cfg.generated_id, file_extension));

// let mysql_dump = select_mysql_path(&version).join("mysqldump");
// info!("MySQL dump found: {}", mysql_dump.display());

let output = Command::new("mysqldump")
.arg("--host")
.arg(cfg.host)
.arg(&cfg.host)
.arg("--port")
.arg(cfg.port.to_string())
.arg("--user")
.arg(cfg.username)
.arg(&cfg.username)
.arg("--routines")
.arg("--events")
.arg("--triggers")
.arg("--verbose")
.arg("--single-transaction")
.arg("--quick")
.arg("--add-drop-database")
.arg("--databases")
.arg(cfg.database)
.arg("--skip-lock-tables")
.arg("--skip-add-drop-table")
.arg("--no-create-db") // IMPORTANT
.arg("--default-character-set=utf8mb4")
.arg(&cfg.database) // IMPORTANT: NOT --databases
.arg("-r")
.arg(&file_path)
.envs(env)
Expand Down
3 changes: 2 additions & 1 deletion src/domain/mysql/restore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ pub async fn run(cfg: DatabaseConfig, restore_file: PathBuf) -> Result<()> {
.with_context(|| format!("Failed to read restore file {}", restore_file.display()))?;

let drop_create_cmd = format!(
"DROP DATABASE IF EXISTS {0}; CREATE DATABASE {0};",
"DROP DATABASE IF EXISTS `{0}`; CREATE DATABASE `{0}`;",
cfg.database
);

Expand Down Expand Up @@ -47,6 +47,7 @@ pub async fn run(cfg: DatabaseConfig, restore_file: PathBuf) -> Result<()> {
.arg(cfg.port.to_string())
.arg("--user")
.arg(&cfg.username)
.arg("--database")
.arg(&cfg.database)
.env("MYSQL_PWD", &cfg.password)
.stdin(std::process::Stdio::piped())
Expand Down
8 changes: 4 additions & 4 deletions src/domain/postgres/restore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ pub async fn run(
info!("Connections terminated for database {}", cfg.name);

let url = format!(
"postgresql://{}:{}@{}:{}/postgres",
cfg.username, cfg.password, cfg.host, cfg.port
"postgresql://{}:{}@{}:{}/{}",
cfg.username, cfg.password, cfg.host, cfg.port, cfg.database
);

debug!("Restore URL: {}", url);
Expand All @@ -51,7 +51,7 @@ pub async fn run(
.arg("--no-privileges")
.arg("--clean")
.arg("--if-exists")
.arg("--create")
// .arg("--create")
.arg("--dbname")
.arg(&url)
.arg("-v")
Expand Down Expand Up @@ -139,7 +139,7 @@ pub async fn run(
.arg("--no-privileges")
.arg("--clean")
.arg("--if-exists")
.arg("--create")
// .arg("--create")
.arg("--dbname")
.arg(&url)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
.arg("-v")
Expand Down
1 change: 0 additions & 1 deletion src/services/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ impl StatusService {
let db_engine = DatabaseFactory::create_for_backup(db.clone()).await;

let reachable = db_engine.ping().await?;

info!("Ping {} => {:?}",db.name, reachable);

Ok::<DatabasePayload, anyhow::Error>(DatabasePayload {
Expand Down
Loading