mirror of
https://github.com/Portabase/agent.git
synced 2026-10-19 19:01:37 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2fc84e7108 | |||
| 8d184dc03e | |||
| b0df1eea20 | |||
| 036995eac1 | |||
| f639cccd8b | |||
| 56102b544f | |||
| 18ac95a454 | |||
| 98c46979ac | |||
| 987d56aaf3 | |||
| 2d44333be7 | |||
| d278abe1b4 | |||
| ba02799c78 | |||
| bd76ac365b | |||
| 9cda3cca61 |
+1
-1
@@ -27,5 +27,5 @@ keywords:
|
||||
- self-hosted
|
||||
- portabase
|
||||
license: Apache-2.0
|
||||
version: 1.22.0
|
||||
version: 1.23.1
|
||||
date-released: '2026-02-24'
|
||||
|
||||
Generated
+1
-1
@@ -3642,7 +3642,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "portabase-agent"
|
||||
version = "1.22.0"
|
||||
version = "1.23.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "portabase-agent"
|
||||
version = "1.22.0"
|
||||
version = "1.23.1"
|
||||
edition = "2024"
|
||||
license = "Apache-2.0"
|
||||
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ services:
|
||||
LOG: debug
|
||||
TZ: "Europe/Paris"
|
||||
# TMPDIR: /scratch
|
||||
EDGE_KEY: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiMjcyMWFiNGQtZDkzNi00NmFkLWE1MWEtOWVhYTk3ZjJhMjAwIiwibWFzdGVyS2V5QjY0IjoiQlhWM1hvbEM2NTZTVjdkTmdjV1BHUWxrKytycExJNmxHRGk3Q1BCNWllbz0ifQ=="
|
||||
EDGE_KEY: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiN2ZjMGViODMtYTY0YS00NzI3LTg5ZjktNzFhZWJmZDQ3NmEyIiwibWFzdGVyS2V5QjY0IjoiQlhWM1hvbEM2NTZTVjdkTmdjV1BHUWxrKytycExJNmxHRGk3Q1BCNWllbz0ifQ=="
|
||||
#CHUNK_SIZE_MB: "1"
|
||||
#POOLING: 1
|
||||
#RETRY_ATTEMPTS: 3
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::services::restore::RestoreService;
|
||||
use crate::services::status::StatusService;
|
||||
use crate::settings::CONFIG;
|
||||
use crate::utils::common::BackupMethod;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info, warn};
|
||||
@@ -59,6 +60,13 @@ impl Agent {
|
||||
error!("Failed to persist dashboard cache: {e}");
|
||||
}
|
||||
|
||||
let live_ids: HashSet<&str> = ping_result
|
||||
.databases
|
||||
.iter()
|
||||
.map(|d| d.generated_id.as_str())
|
||||
.collect();
|
||||
self.cron_service.prune(&live_ids).await;
|
||||
|
||||
let merged = merge(&local.databases, &self.dashboard_cache);
|
||||
|
||||
for db in ping_result.databases.iter() {
|
||||
|
||||
@@ -34,6 +34,14 @@ pub async fn run(
|
||||
let pg_dump = select_pg_path(&version).join("pg_dump");
|
||||
logger.log("debug", format!("Using pg_dump at {:?}", pg_dump));
|
||||
|
||||
let (excludes, invalid) = exclude_args(&cfg);
|
||||
for key in invalid {
|
||||
logger.log("warn", format!("Ignoring options.{} for {}: expected a list of patterns", key, cfg.name));
|
||||
}
|
||||
if !excludes.is_empty() {
|
||||
logger.log("info", format!("Excluding from dump of {}: {}", cfg.name, excludes.join(" ")));
|
||||
}
|
||||
|
||||
match format {
|
||||
PostgresDumpFormat::Fc => {
|
||||
logger.log("info", format!("Running FC backup for {}", cfg.name));
|
||||
@@ -46,6 +54,7 @@ pub async fn run(
|
||||
.arg("--port").arg(cfg.port.to_string())
|
||||
.arg("--username").arg(&cfg.username)
|
||||
.arg("--dbname").arg(&cfg.database)
|
||||
.args(&excludes)
|
||||
.arg("-Fc")
|
||||
.arg("-f").arg(&file_path)
|
||||
.arg("-v")
|
||||
@@ -97,6 +106,7 @@ pub async fn run(
|
||||
.arg("--port").arg(cfg.port.to_string())
|
||||
.arg("--username").arg(&cfg.username)
|
||||
.arg("--dbname").arg(&cfg.database)
|
||||
.args(&excludes)
|
||||
.arg("-Fd")
|
||||
.arg("-j").arg("4")
|
||||
.arg("-f").arg(&dump_dir)
|
||||
@@ -151,3 +161,23 @@ pub async fn run(
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
pub(crate) fn exclude_args(cfg: &DatabaseConfig) -> (Vec<String>, Vec<&'static str>) {
|
||||
let mut args = Vec::new();
|
||||
let mut invalid = Vec::new();
|
||||
for (key, flag) in [("exclude_tables", "--exclude-table"), ("exclude_schemas", "--exclude-schema")] {
|
||||
match cfg.options.get(key).map(|v| v.as_array()) {
|
||||
None => {}
|
||||
Some(Some(items)) => args.extend(
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|p| format!("{flag}={p}")),
|
||||
),
|
||||
Some(None) => invalid.push(key),
|
||||
}
|
||||
}
|
||||
(args, invalid)
|
||||
}
|
||||
|
||||
+43
-1
@@ -5,9 +5,31 @@ use crate::services::api::models::agent::status::DatabaseStatus;
|
||||
use crate::utils::common::vec_to_option_json;
|
||||
use crate::utils::redis_client;
|
||||
use crate::utils::task_manager::cron::check_and_update_cron;
|
||||
use crate::utils::task_manager::tasks::{SCHEDULE_KEY, remove_task};
|
||||
use redis::AsyncCommands;
|
||||
use redis::aio::MultiplexedConnection;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info};
|
||||
|
||||
const BACKUP_TASK_PREFIX: &str = "periodic.backup_";
|
||||
|
||||
pub fn backup_task_name(generated_id: &str) -> String {
|
||||
format!("{BACKUP_TASK_PREFIX}{generated_id}")
|
||||
}
|
||||
|
||||
pub fn stale_backup_tasks(scheduled_keys: &[String], live_ids: &HashSet<&str>) -> Vec<String> {
|
||||
scheduled_keys
|
||||
.iter()
|
||||
.filter_map(|key| key.strip_prefix("redbeat:"))
|
||||
.filter(|name| {
|
||||
name.strip_prefix(BACKUP_TASK_PREFIX)
|
||||
.is_some_and(|id| !live_ids.contains(id))
|
||||
})
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub struct CronService {
|
||||
ctx: Arc<Context>,
|
||||
@@ -23,7 +45,7 @@ impl CronService {
|
||||
pub async fn sync(&mut self, database: &DatabaseStatus) -> Result<bool, String> {
|
||||
let generated_id = database.generated_id.as_str();
|
||||
let dbms = database.dbms.as_str();
|
||||
let task_name = format!("periodic.backup_{}", generated_id);
|
||||
let task_name = backup_task_name(generated_id);
|
||||
let args = vec![generated_id.to_string(), dbms.to_string()];
|
||||
let storages: Option<Value> = vec_to_option_json(database.storages.clone());
|
||||
let encrypt: bool = database.encrypt;
|
||||
@@ -44,4 +66,24 @@ impl CronService {
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub async fn prune(&mut self, live_ids: &HashSet<&str>) {
|
||||
let keys: Vec<String> = match self.conn.zrange(SCHEDULE_KEY, 0, -1).await {
|
||||
Ok(keys) => keys,
|
||||
Err(e) => {
|
||||
error!("Failed to list scheduled tasks: {:?}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
for task_name in stale_backup_tasks(&keys, live_ids) {
|
||||
match remove_task(&mut self.conn, &task_name).await {
|
||||
Ok(()) => info!(
|
||||
"Task {} removed: database no longer returned by dashboard",
|
||||
task_name
|
||||
),
|
||||
Err(e) => error!("Failed to remove task {}: {:?}", task_name, e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,6 +316,62 @@ async fn restore_run_unified_fc_roundtrip() {
|
||||
assert!(result.is_ok(), "restore::run failed: {:?}", result);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backup_as_non_owner_succeeds_only_with_exclude_tables() {
|
||||
init_tracing_for_test();
|
||||
|
||||
let (_container, admin) = create_config().await;
|
||||
crate::domain::postgres::connection::connect(&admin)
|
||||
.await
|
||||
.unwrap()
|
||||
.batch_execute(
|
||||
"CREATE ROLE backup_ro LOGIN PASSWORD 'x';
|
||||
CREATE TABLE public.keep(id int);
|
||||
CREATE TABLE public.search_index__x(id int);
|
||||
GRANT SELECT ON public.keep TO backup_ro;",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut ro = admin.clone();
|
||||
ro.username = "backup_ro".into();
|
||||
ro.password = "x".into();
|
||||
|
||||
let mut excluded = ro.clone();
|
||||
excluded
|
||||
.options
|
||||
.insert("exclude_tables".into(), serde_json::json!(["public.search_index__*"]));
|
||||
|
||||
for format in [PostgresDumpFormat::Fc, PostgresDumpFormat::Fd] {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let denied = crate::domain::postgres::backup::run(
|
||||
ro.clone(), format, dir.path().to_path_buf(), pg_dump_env(&ro), Arc::new(JobLogger::new()),
|
||||
)
|
||||
.await;
|
||||
assert!(denied.is_err(), "{format:?}: non-owner dump must fail without exclusions");
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let logger = Arc::new(JobLogger::new());
|
||||
let dump = crate::domain::postgres::backup::run(
|
||||
excluded.clone(), format, dir.path().to_path_buf(), pg_dump_env(&excluded), logger.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("{format:?}: dump with exclusions failed: {e:?}"));
|
||||
|
||||
let entries = Arc::try_unwrap(logger).expect("single owner").into_entries();
|
||||
assert!(entries.iter().any(|e| e.message
|
||||
== format!("Excluding from dump of {}: --exclude-table=public.search_index__*", excluded.name)));
|
||||
|
||||
if format == PostgresDumpFormat::Fc {
|
||||
let version = server_version(&admin).await.unwrap();
|
||||
let pg_restore = select_pg_path(&version).join(pg_restore_binary_name());
|
||||
let prepared = prepare_archive(format, &dump, &pg_restore, &JobLogger::new()).unwrap();
|
||||
assert!(prepared.toc().contains("TABLE public keep "));
|
||||
assert!(!prepared.toc().contains("search_index__x"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drop_all_schemas_removes_user_schema() {
|
||||
init_tracing_for_test();
|
||||
@@ -924,6 +980,61 @@ mod clean_mode_tests {
|
||||
}
|
||||
}
|
||||
|
||||
mod exclude_args_tests {
|
||||
use crate::domain::postgres::backup::exclude_args;
|
||||
use crate::services::config::DatabaseConfig;
|
||||
use serde_json::json;
|
||||
|
||||
fn cfg(options: serde_json::Value) -> DatabaseConfig {
|
||||
serde_json::from_value(json!({
|
||||
"name": "t", "database": "testdb", "type": "postgresql",
|
||||
"username": "u", "password": "p", "port": 5432, "host": "localhost",
|
||||
"generated_id": "00000000-0000-0000-0000-000000000000",
|
||||
"path": "", "max_packet_size": "", "volume_name": "",
|
||||
"options": options
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_options_yields_nothing() {
|
||||
assert_eq!(exclude_args(&cfg(json!({}))), (vec![], vec![]));
|
||||
assert_eq!(
|
||||
exclude_args(&cfg(json!({"exclude_tables": [], "exclude_schemas": []}))),
|
||||
(vec![], vec![])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_map_to_single_arg_flags_in_order() {
|
||||
let (args, invalid) = exclude_args(&cfg(json!({
|
||||
"exclude_schemas": ["metabase_tmp"],
|
||||
"exclude_tables": ["public.search_index__*", " audit.tmp_* ", "", " ", 42, "-f/tmp/x", "\"MyTable\""]
|
||||
})));
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"--exclude-table=public.search_index__*",
|
||||
"--exclude-table=audit.tmp_*",
|
||||
"--exclude-table=-f/tmp/x",
|
||||
"--exclude-table=\"MyTable\"",
|
||||
"--exclude-schema=metabase_tmp",
|
||||
]
|
||||
);
|
||||
assert!(invalid.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_list_values_are_reported_and_ignored() {
|
||||
let (args, invalid) = exclude_args(&cfg(json!({
|
||||
"exclude_tables": "public.search_index__*",
|
||||
"exclude_schemas": {"a": 1}
|
||||
})));
|
||||
assert!(args.is_empty());
|
||||
assert_eq!(invalid, vec!["exclude_tables", "exclude_schemas"]);
|
||||
}
|
||||
}
|
||||
|
||||
mod toc_tests {
|
||||
use crate::domain::postgres::restore::toc_creates_public_schema;
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
use crate::services::cron::{backup_task_name, stale_backup_tasks};
|
||||
use std::collections::HashSet;
|
||||
|
||||
const ID_A: &str = "16678159-ff7e-4c97-8c83-0adeff214681";
|
||||
const ID_B: &str = "16678124-ff7e-4c97-8c83-0adeff214681";
|
||||
|
||||
fn key(id: &str) -> String {
|
||||
format!("redbeat:{}", backup_task_name(id))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_task_name_format() {
|
||||
assert_eq!(backup_task_name(ID_A), format!("periodic.backup_{ID_A}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_backup_tasks_returns_ids_missing_from_response() {
|
||||
let live: HashSet<&str> = [ID_A].into();
|
||||
assert_eq!(
|
||||
stale_backup_tasks(&[key(ID_A), key(ID_B)], &live),
|
||||
vec![backup_task_name(ID_B)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_backup_tasks_empty_response_prunes_all() {
|
||||
assert_eq!(
|
||||
stale_backup_tasks(&[key(ID_A), key(ID_B)], &HashSet::new()),
|
||||
vec![backup_task_name(ID_A), backup_task_name(ID_B)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_backup_tasks_ignores_other_keys() {
|
||||
let keys = vec![
|
||||
"redbeat:periodic.other_x".to_string(),
|
||||
"periodic.backup_raw".to_string(),
|
||||
];
|
||||
assert!(stale_backup_tasks(&keys, &HashSet::new()).is_empty());
|
||||
}
|
||||
@@ -2,5 +2,6 @@ mod api_models_tests;
|
||||
mod backup_runner_tests;
|
||||
mod backup_uploader_tests;
|
||||
mod config_tests;
|
||||
mod cron_tests;
|
||||
mod dashboard_config_tests;
|
||||
mod restore_downloader_tests;
|
||||
|
||||
+1
-2
@@ -1,7 +1,6 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::fs::File;
|
||||
@@ -54,7 +53,7 @@ pub fn full_file_path(file_name: &String, folder_name: Option<&str>) -> String {
|
||||
.filter(|folder_name| !folder_name.is_empty())
|
||||
.unwrap_or("backups");
|
||||
|
||||
format!("{}/{}/{}", folder_name, Utc::now().format("%Y-%m-%d"), file_name)
|
||||
format!("{}/{}/{}", folder_name, chrono::Local::now().format("%Y-%m-%d"), file_name)
|
||||
}
|
||||
|
||||
const CHUNK_SIZE: usize = 16 * 1024 * 1024;
|
||||
|
||||
Reference in New Issue
Block a user