Compare commits

...

8 Commits

Author SHA1 Message Date
github-actions[bot] 2fc84e7108 chore: release 1.23.1 2026-10-17 18:27:27 +00:00
Charles Gauthereau 8d184dc03e Merge pull request #122 from Portabase/dev
Dev
2026-10-17 20:25:10 +02:00
Charles Gauthereau b0df1eea20 Merge pull request #123 from Portabase/fix/backup-creation-timezone
fix: backup-creation-timezone
2026-10-17 19:39:41 +02:00
Charles Gauthereau 036995eac1 fix: refactoring 2026-10-17 19:37:17 +02:00
Charles Gauthereau f639cccd8b fix: use local time for the backup date folder
The remote path date folder used UTC, so backups taken after local
midnight in non-UTC zones landed in the previous day's folder. Use the
container timezone (TZ), like the scheduler already does.

Refs Portabase/portabase#330
2026-10-17 19:35:52 +02:00
Charles Gauthereau 56102b544f Merge pull request #121 from Portabase/fix/managed-database-reappears-after-deletion
fix: managed-database-reappears-after-deletion
2026-10-17 19:03:54 +02:00
Charles Gauthereau 18ac95a454 fix: refactoring 2026-10-17 18:40:54 +02:00
Charles Gauthereau 98c46979ac fix: remove scheduled backups of databases no longer returned by the dashboard 2026-10-17 17:52:28 +02:00
8 changed files with 96 additions and 6 deletions
+1 -1
View File
@@ -27,5 +27,5 @@ keywords:
- self-hosted - self-hosted
- portabase - portabase
license: Apache-2.0 license: Apache-2.0
version: 1.23.0 version: 1.23.1
date-released: '2026-02-24' date-released: '2026-02-24'
Generated
+1 -1
View File
@@ -3642,7 +3642,7 @@ dependencies = [
[[package]] [[package]]
name = "portabase-agent" name = "portabase-agent"
version = "1.23.0" version = "1.23.1"
dependencies = [ dependencies = [
"aes", "aes",
"aes-gcm", "aes-gcm",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "portabase-agent" name = "portabase-agent"
version = "1.23.0" version = "1.23.1"
edition = "2024" edition = "2024"
license = "Apache-2.0" license = "Apache-2.0"
+8
View File
@@ -9,6 +9,7 @@ use crate::services::restore::RestoreService;
use crate::services::status::StatusService; use crate::services::status::StatusService;
use crate::settings::CONFIG; use crate::settings::CONFIG;
use crate::utils::common::BackupMethod; use crate::utils::common::BackupMethod;
use std::collections::HashSet;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use tracing::{error, info, warn}; use tracing::{error, info, warn};
@@ -59,6 +60,13 @@ impl Agent {
error!("Failed to persist dashboard cache: {e}"); 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); let merged = merge(&local.databases, &self.dashboard_cache);
for db in ping_result.databases.iter() { for db in ping_result.databases.iter() {
+43 -1
View File
@@ -5,9 +5,31 @@ use crate::services::api::models::agent::status::DatabaseStatus;
use crate::utils::common::vec_to_option_json; use crate::utils::common::vec_to_option_json;
use crate::utils::redis_client; use crate::utils::redis_client;
use crate::utils::task_manager::cron::check_and_update_cron; 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 redis::aio::MultiplexedConnection;
use serde_json::{Value, json}; use serde_json::{Value, json};
use std::collections::HashSet;
use std::sync::Arc; 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 { pub struct CronService {
ctx: Arc<Context>, ctx: Arc<Context>,
@@ -23,7 +45,7 @@ impl CronService {
pub async fn sync(&mut self, database: &DatabaseStatus) -> Result<bool, String> { pub async fn sync(&mut self, database: &DatabaseStatus) -> Result<bool, String> {
let generated_id = database.generated_id.as_str(); let generated_id = database.generated_id.as_str();
let dbms = database.dbms.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 args = vec![generated_id.to_string(), dbms.to_string()];
let storages: Option<Value> = vec_to_option_json(database.storages.clone()); let storages: Option<Value> = vec_to_option_json(database.storages.clone());
let encrypt: bool = database.encrypt; let encrypt: bool = database.encrypt;
@@ -44,4 +66,24 @@ impl CronService {
Ok(true) 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),
}
}
}
} }
+40
View File
@@ -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());
}
+1
View File
@@ -2,5 +2,6 @@ mod api_models_tests;
mod backup_runner_tests; mod backup_runner_tests;
mod backup_uploader_tests; mod backup_uploader_tests;
mod config_tests; mod config_tests;
mod cron_tests;
mod dashboard_config_tests; mod dashboard_config_tests;
mod restore_downloader_tests; mod restore_downloader_tests;
+1 -2
View File
@@ -1,7 +1,6 @@
#![allow(dead_code)] #![allow(dead_code)]
use anyhow::Result; use anyhow::Result;
use chrono::Utc;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use tokio::fs::File; 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()) .filter(|folder_name| !folder_name.is_empty())
.unwrap_or("backups"); .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; const CHUNK_SIZE: usize = 16 * 1024 * 1024;