Compare commits

..

16 Commits

Author SHA1 Message Date
github-actions[bot] f050fa35e0 chore: release 1.17.1 2026-07-21 21:29:30 +00:00
Charles GTE 90d55f7630 fix: backup folder name (#86)
* fix: support configurable backup file prefix (#83)

* fix: backup folder name

* fix: ghcr publish agent image

---------

Co-authored-by: Antonin Jousson <18756890+Antoninj@users.noreply.github.com>
Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-07-21 23:27:10 +02:00
github-actions[bot] 82ffb71496 chore: release 1.17.0 2026-07-11 10:08:21 +00:00
Charles GTE 20b3b58024 Merge pull request #80 from Portabase/fix/storage-encryption
fix/storage-encryption
2026-07-11 12:05:59 +02:00
charles-gauthereau 6cddc73565 Merge branch 'main' into fix/storage-encryption
# Conflicts:
#	docker-compose.yml
2026-07-10 18:49:01 +02:00
github-actions[bot] fd3b0076c0 chore: release 1.16.3 2026-07-09 09:11:23 +00:00
Charles GTE 334ab51125 fix: cron (#79)
* fix: cron log

---------

Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-07-09 11:03:55 +02:00
github-actions[bot] 4df3fe2d0c chore: release 1.16.2 2026-07-09 07:38:49 +00:00
Charles GTE ec7715ca26 fix: cron (#78)
* fix: docker-compose.yml

* fix: cron

---------

Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-07-09 09:36:53 +02:00
charles-gauthereau 32d4e22196 fix: status.rs 2026-07-08 22:24:50 +02:00
charles-gauthereau f277c5485f feat: decrypt encrypted storages in status ping 2026-07-08 20:52:17 +02:00
charles-gauthereau 585e0bea72 feat: accept encrypted storages markers in DatabaseStatus 2026-07-08 20:47:20 +02:00
charles-gauthereau ee97c6056a feat: add decrypt_json_gcm for encrypted status storages 2026-07-08 20:43:01 +02:00
charles-gauthereau b37a4f2180 fix: docker-compose.yml 2026-07-07 18:43:30 +02:00
github-actions[bot] 1d15f40662 chore: release 1.16.1 2026-07-04 10:26:50 +00:00
Charles GTE 324a2ea3f2 fix: security (#75) 2026-07-04 12:24:37 +02:00
23 changed files with 384 additions and 52 deletions
+72
View File
@@ -0,0 +1,72 @@
name: GHCR Publish
on:
workflow_call:
inputs:
version:
required: true
type: string
ref:
required: true
type: string
add_latest:
required: false
type: boolean
default: false
dockerfile:
required: false
type: string
default: "./docker/Dockerfile"
target:
required: false
type: string
default: "prod"
permissions:
contents: read
packages: write
jobs:
publish:
name: Build and push to GHCR
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/agent
tags: |
type=semver,pattern={{version}},value=${{ inputs.version }}
type=semver,pattern={{major}}.{{minor}},value=${{ inputs.version }}
type=semver,pattern={{major}},value=${{ inputs.version }}
type=raw,value=latest,enable=${{ inputs.add_latest }}
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
file: ${{ inputs.dockerfile }}
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
target: ${{ inputs.target }}
provenance: false
cache-from: type=gha,scope=ghcr-build
cache-to: type=gha,mode=max,scope=ghcr-build,ignore-error=true
+10
View File
@@ -100,6 +100,15 @@ jobs:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
publish-docker-ghcr:
needs: create-release
if: ${{ needs.create-release.result == 'success' }}
uses: ./.github/workflows/ghcr.yml
with:
version: ${{ needs.create-release.outputs.version }}
ref: ${{ needs.create-release.outputs.version }}
add_latest: true
publish-helm:
needs: create-release
if: ${{ needs.create-release.result == 'success' }}
@@ -113,6 +122,7 @@ jobs:
needs:
- create-release
- publish-docker
- publish-docker-ghcr
- publish-helm
runs-on: ubuntu-latest
outputs:
+1 -1
View File
@@ -27,5 +27,5 @@ keywords:
- self-hosted
- portabase
license: Apache-2.0
version: 1.16.0
version: 1.17.1
date-released: '2026-02-24'
Generated
+1 -1
View File
@@ -3503,7 +3503,7 @@ dependencies = [
[[package]]
name = "portabase-agent"
version = "1.16.0"
version = "1.17.1"
dependencies = [
"aes",
"aes-gcm",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "portabase-agent"
version = "1.16.0"
version = "1.17.1"
edition = "2024"
[dependencies]
+3 -1
View File
@@ -15,11 +15,13 @@ services:
# - cargo-target:/app/target
- databases_sqlite-data:/sqlite-data/workspace/data
- ./scripts/sqlite/test-db:/sqlite-data-2/workspace/data
# - /bigdisk:/scratch
environment:
APP_ENV: development
LOG: debug
TZ: "Europe/Paris"
EDGE_KEY: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiNDA1MTA4YzQtMDJjYy00NTlhLTkxNjItODExNTc3NjAzMjhjIiwibWFzdGVyS2V5QjY0IjoiMUh0djdtWCtYVkJxL0IzUEV2WDlZZjlQeUdVZW5oRHlXemo5THRqNW90WT0ifQ=="
# TMPDIR: /scratch
EDGE_KEY: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiNTljYzRjYTUtOTAyNy00ZThiLTk1NDktMjAzOTI3ZDVjNmUyIiwibWFzdGVyS2V5QjY0IjoiMUh0djdtWCtYVkJxL0IzUEV2WDlZZjlQeUdVZW5oRHlXemo5THRqNW90WT0ifQ=="
#CHUNK_SIZE_MB: "1"
#POOLING: 1
#DATABASES_CONFIG_FILE: "config.toml"
+7
View File
@@ -24,6 +24,8 @@ pub struct DatabaseStorage {
#[serde(deserialize_with = "deserialize_snake_case")]
pub config: Value,
pub provider: String,
#[serde(default, rename = "folderName")]
pub folder_name: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -31,7 +33,12 @@ pub struct DatabaseStatus {
pub dbms: String,
#[serde(rename = "generatedId")]
pub generated_id: String,
#[serde(default)]
pub storages: Vec<DatabaseStorage>,
#[serde(default)]
pub storages_encrypted: Option<bool>,
#[serde(default)]
pub storages_ciphertext: Option<String>,
pub encrypt: bool,
pub data: DatabaseData,
}
+1
View File
@@ -104,6 +104,7 @@ impl BackupService {
method,
&storage,
Some(encrypt),
&backup_storage_id,
)
.await;
+25 -8
View File
@@ -1,16 +1,18 @@
#![allow(dead_code)]
use crate::core::context::Context;
use crate::domain::factory::DatabaseFactory;
use crate::services::api::endpoints::status::DatabasePayload;
use crate::services::api::models::agent::status::DatabaseStorage;
use crate::services::api::models::agent::status::PingResult;
use crate::services::config::DatabaseConfig;
use crate::settings::CONFIG;
use crate::utils::file::decrypt_json_gcm;
use futures_util::future::try_join_all;
use reqwest::Client;
use std::error::Error;
use std::sync::Arc;
use futures_util::future::try_join_all;
use tracing::info;
use crate::domain::factory::DatabaseFactory;
pub struct StatusService {
ctx: Arc<Context>,
@@ -28,12 +30,12 @@ impl StatusService {
pub async fn ping(&self, databases: &[DatabaseConfig]) -> Result<PingResult, Box<dyn Error>> {
let edge_key = &self.ctx.edge_key;
let databases_payload: Vec<DatabasePayload> = try_join_all(
databases.into_iter().map(|db| async move {
let databases_payload: Vec<DatabasePayload> =
try_join_all(databases.into_iter().map(|db| async move {
let db_engine = DatabaseFactory::create_for_backup(db.clone()).await;
let reachable = db_engine.ping().await?;
info!("Ping {} => {:?}",db.name, reachable);
info!("Ping {} => {:?}", db.name, reachable);
Ok::<DatabasePayload, anyhow::Error>(DatabasePayload {
name: &db.name,
@@ -41,16 +43,31 @@ impl StatusService {
generated_id: &db.generated_id,
ping_status: reachable,
})
})
).await?;
}))
.await?;
let version_str = CONFIG.app_version.as_str();
let result = self
let mut result = self
.ctx
.api
.agent_status(&edge_key.agent_id, &version_str, databases_payload)
.await?
.unwrap();
for db in result.databases.iter_mut() {
if db.storages_encrypted == Some(true) {
let ciphertext = db
.storages_ciphertext
.as_deref()
.ok_or("storages_encrypted set but storages_ciphertext missing")?;
let plaintext = decrypt_json_gcm(ciphertext, &edge_key.master_key_b64)
.map_err(|e| format!("Failed to decrypt storages: {e}"))?;
db.storages = serde_json::from_slice::<Vec<DatabaseStorage>>(&plaintext)
.map_err(|e| format!("Failed to parse decrypted storages: {e}"))?;
}
}
Ok(result)
}
}
+1
View File
@@ -22,6 +22,7 @@ pub trait StorageProvider: Send + Sync {
method: BackupMethod,
config: &DatabaseStorage,
encrypt: Option<bool>,
backup_storage_id: &str,
) -> UploadResult;
}
@@ -26,6 +26,7 @@ impl StorageProvider for AzureBlobProvider {
_method: BackupMethod,
storage: &DatabaseStorage,
encrypt: Option<bool>,
_backup_storage_id: &str,
) -> UploadResult {
let Some(file_path) = result.backup_file else {
return UploadResult {
@@ -94,7 +95,7 @@ impl StorageProvider for AzureBlobProvider {
};
let file_name = full_file_name(encrypt);
let remote_file_path = full_file_path(&file_name);
let remote_file_path = full_file_path(&file_name, storage.folder_name.as_deref());
info!(
"Starting block upload to azure blob {}/{}",
config.container_name, remote_file_path
@@ -28,6 +28,7 @@ impl StorageProvider for GoogleCloudStorageProvider {
_method: BackupMethod,
storage: &DatabaseStorage,
encrypt: Option<bool>,
_backup_storage_id: &str,
) -> UploadResult {
let Some(file_path) = result.backup_file else {
return UploadResult {
@@ -84,7 +85,7 @@ impl StorageProvider for GoogleCloudStorageProvider {
let file_name = full_file_name(encrypt);
info!("Uploading file {}", file_name);
let remote_file_path = full_file_path(&file_name);
let remote_file_path = full_file_path(&file_name, storage.folder_name.as_deref());
let client = match build_client(&config).await {
Ok(c) => c,
@@ -26,6 +26,7 @@ impl StorageProvider for GoogleDriveProvider {
_method: BackupMethod,
storage: &DatabaseStorage,
encrypt: Option<bool>,
_backup_storage_id: &str,
) -> UploadResult {
let Some(file_path) = result.backup_file else {
return UploadResult {
@@ -84,7 +85,7 @@ impl StorageProvider for GoogleDriveProvider {
info!("Uploading file {}", file_name);
let remote_file_path = full_file_path(&file_name);
let remote_file_path = full_file_path(&file_name, storage.folder_name.as_deref());
match upload_stream_to_google_drive(
&config,
+6 -1
View File
@@ -23,6 +23,7 @@ impl StorageProvider for LocalProvider {
method: BackupMethod,
storage: &DatabaseStorage,
encrypt: Option<bool>,
backup_storage_id: &str,
) -> UploadResult {
let Some(file_path) = result.backup_file else {
return UploadResult {
@@ -37,7 +38,7 @@ impl StorageProvider for LocalProvider {
let encrypt = encrypt.unwrap_or(false);
let file_name = full_file_name(encrypt);
let remote_file_path = full_file_path(&file_name);
let remote_file_path = full_file_path(&file_name, storage.folder_name.as_deref());
let total_size = match fs::metadata(&file_path).await {
Ok(meta) => meta.len(),
@@ -82,6 +83,10 @@ impl StorageProvider for LocalProvider {
"X-Generated-Id",
HeaderValue::from_str(&result.generated_id).unwrap(),
);
extra_headers.insert(
"X-Backup-Storage-Id",
HeaderValue::from_str(backup_storage_id).unwrap(),
);
extra_headers.insert("X-Status", HeaderValue::from_str(&result.status).unwrap());
extra_headers.insert(
"X-Method",
+2 -1
View File
@@ -34,6 +34,7 @@ impl StorageProvider for S3Provider {
_method: BackupMethod,
storage: &DatabaseStorage,
encrypt: Option<bool>,
_backup_storage_id: &str,
) -> UploadResult {
let Some(file_path) = result.backup_file else {
return UploadResult {
@@ -147,7 +148,7 @@ impl StorageProvider for S3Provider {
info!("Uploading file {}", file_name);
let bucket = &config.bucket_name;
let remote_file_path = full_file_path(&file_name);
let remote_file_path = full_file_path(&file_name, storage.folder_name.as_deref());
info!("S3 key {:}", remote_file_path);
info!(
"Starting multipart upload to s3://{}/{}",
+39 -1
View File
@@ -2,7 +2,7 @@ use serde_json::json;
use crate::services::api::models::agent::backup::{BackupResponse, BackupUploadResponse};
use crate::services::api::models::agent::restore::ResultRestoreResponse;
use crate::services::api::models::agent::status::PingResult;
use crate::services::api::models::agent::status::{DatabaseStatus, PingResult};
#[test]
fn backup_response_deserializes_nested_backup_id() {
@@ -110,3 +110,41 @@ fn ping_result_deserializes_and_normalizes_storage_config_keys() {
assert!(result.databases[0].data.restore.file.is_none());
assert!(result.databases[0].data.restore.meta_file.is_none());
}
#[test]
fn database_status_legacy_plaintext_storages() {
let status: DatabaseStatus = serde_json::from_value(json!({
"dbms": "postgres",
"generatedId": "gen-1",
"storages": [ { "id": "s1", "config": { "bucket": "b" }, "provider": "s3" } ],
"encrypt": true,
"data": {
"backup": { "action": false, "cron": null },
"restore": { "action": false, "file": null, "metaFile": null, "size": null }
}
})).unwrap();
assert_eq!(status.storages.len(), 1);
assert_eq!(status.storages_encrypted, None);
assert!(status.storages_ciphertext.is_none());
}
#[test]
fn database_status_encrypted_envelope() {
let status: DatabaseStatus = serde_json::from_value(json!({
"dbms": "postgres",
"generatedId": "gen-1",
"storages": [],
"storages_encrypted": true,
"storages_ciphertext": "AQIDBA==",
"encrypt": true,
"data": {
"backup": { "action": true, "cron": null },
"restore": { "action": false, "file": null, "metaFile": null, "size": null }
}
})).unwrap();
assert!(status.storages.is_empty());
assert_eq!(status.storages_encrypted, Some(true));
assert_eq!(status.storages_ciphertext.as_deref(), Some("AQIDBA=="));
}
+32 -4
View File
@@ -30,12 +30,16 @@ fn full_file_name_matches_expected_suffix() {
}
#[test]
fn full_file_path_prefixes_backups_directory_and_date() {
fn full_file_path_uses_default_or_configured_folder_name() {
let file_name = "backup.tar.gz".to_string();
let full_path = full_file_path(&file_name);
assert!(full_path.starts_with("backups/"));
assert!(full_path.ends_with("/backup.tar.gz"));
let default_path = full_file_path(&file_name, None);
assert!(default_path.starts_with("backups/"));
assert!(default_path.ends_with("/backup.tar.gz"));
let configured_path = full_file_path(&file_name, Some("/portabase/"));
assert!(configured_path.starts_with("portabase/"));
assert!(configured_path.ends_with("/backup.tar.gz"));
}
#[tokio::test]
@@ -86,3 +90,27 @@ async fn encrypt_stream_starts_with_json_header_line() -> Result<()> {
Ok(())
}
use crate::utils::file::decrypt_json_gcm;
const VECTOR_KEY_B64: &str = "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=";
const VECTOR_ENVELOPE_B64: &str = "AQIDBAUGBwgJCgsM4AfK9w7I2A7UDzMvpJaScfnUYAGDZgPWT5Chrp1pdzMPPQVpNjb6ZEiFea9YdWVFv1UEo9RGmmf+zYUv4I3gE4SU/SBrMwkCHEpJGJOzJtK3tSpJmzLVX3+7EeUNwp4qjZheL8p0pe1x6dRUtx3JmLjz1W/RhWd6zuReDItv6+0jg4CaPOHvFXBreaGNCTRslxbImD+lFBoEOvw8lsbH";
const VECTOR_PLAINTEXT: &str = "[{\"id\":\"11111111-1111-1111-1111-111111111111\",\"config\":{\"bucket\":\"my-bucket\",\"accessKeyId\":\"AKIA\",\"secretAccessKey\":\"s3cr3t\"},\"provider\":\"s3\"}]";
#[test]
fn decrypt_json_gcm_decrypts_node_vector() {
let plaintext = decrypt_json_gcm(VECTOR_ENVELOPE_B64, VECTOR_KEY_B64).unwrap();
assert_eq!(String::from_utf8(plaintext).unwrap(), VECTOR_PLAINTEXT);
}
#[test]
fn decrypt_json_gcm_rejects_wrong_key() {
let wrong_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
assert!(decrypt_json_gcm(VECTOR_ENVELOPE_B64, wrong_key).is_err());
}
#[test]
fn decrypt_json_gcm_rejects_short_input() {
// 8 bytes base64 -> shorter than nonce(12)+tag(16)
assert!(decrypt_json_gcm("AAAAAAAAAAA=", VECTOR_KEY_B64).is_err());
}
+49 -1
View File
@@ -31,12 +31,60 @@ fn normalized_expression_is_valid_for_cron_schedule() {
#[test]
fn next_run_timestamp_returns_future_timestamp() {
let expr = normalize_cron("*/1 * * * *");
let ts = next_run_timestamp(&expr);
let ts = next_run_timestamp(&expr).unwrap();
let now = chrono::Local::now().timestamp();
assert!(ts > now);
}
#[test]
fn normalize_converts_unix_sunday_zero_to_crate_dow() {
let input = "00 06 * * 0";
let normalized = normalize_cron(input);
assert_eq!(normalized, "0 00 06 * * 1");
let schedule = Schedule::from_str(&normalized);
assert!(schedule.is_ok());
}
#[test]
fn normalize_converts_unix_saturday_to_crate_dow() {
assert_eq!(normalize_cron("00 06 * * 6"), "0 00 06 * * 7");
}
#[test]
fn normalized_sunday_actually_fires_on_sunday() {
use chrono::{Datelike, Timelike, Weekday};
let normalized = normalize_cron("00 03 * * 0");
assert_eq!(normalized, "0 00 03 * * 1");
let schedule = Schedule::from_str(&normalized).unwrap();
let next = schedule.upcoming(chrono::Utc).next().unwrap();
assert_eq!(next.weekday(), Weekday::Sun);
assert_eq!(next.hour(), 3);
assert_eq!(next.minute(), 0);
}
#[test]
fn normalize_converts_unix_dow_range() {
assert_eq!(normalize_cron("00 06 * * 0-4"), "0 00 06 * * 1-5");
}
#[test]
fn next_run_timestamp_returns_none_for_invalid_cron() {
assert!(next_run_timestamp("not a cron").is_none());
}
#[test]
fn normalize_leaves_out_of_range_dow_untouched() {
let normalized = normalize_cron("* * * * 100");
assert_eq!(normalized, "0 * * * * 100");
assert!(Schedule::from_str(&normalized).is_err());
}
#[test]
fn normalization_does_not_break_schedule_parsing() {
let input = "0 */10 * * * *";
+37 -2
View File
@@ -47,8 +47,14 @@ pub fn full_file_name(encrypt: bool) -> String {
}
}
pub fn full_file_path(file_name: &String) -> String {
format!("backups/{}/{}", Utc::now().format("%Y-%m-%d"), file_name)
pub fn full_file_path(file_name: &String, folder_name: Option<&str>) -> String {
let folder_name = folder_name
.map(str::trim)
.map(|folder_name| folder_name.trim_matches(char::from(47)))
.filter(|folder_name| !folder_name.is_empty())
.unwrap_or("backups");
format!("{}/{}/{}", folder_name, Utc::now().format("%Y-%m-%d"), file_name)
}
const CHUNK_SIZE: usize = 16 * 1024 * 1024;
@@ -175,3 +181,32 @@ pub async fn decrypt_file_stream_gcm(
writer.flush().await?;
Ok(())
}
/// Decrypt a base64 `nonce(12) ‖ ciphertext ‖ tag(16)` AES-256-GCM envelope
/// using the raw master key (STANDARD base64). Returns the plaintext bytes.
pub fn decrypt_json_gcm(ciphertext_b64: &str, master_key_b64: &str) -> Result<Vec<u8>> {
let master_key_bytes = general_purpose::STANDARD
.decode(master_key_b64)
.map_err(|_| anyhow::anyhow!("Invalid base64 master key"))?;
let data = general_purpose::STANDARD
.decode(ciphertext_b64)
.map_err(|_| anyhow::anyhow!("Invalid base64 ciphertext"))?;
if data.len() < 12 + 16 {
return Err(anyhow::anyhow!("Ciphertext too short"));
}
let key = Key::<Aes256Gcm>::try_from(master_key_bytes.as_slice())
.map_err(|_| anyhow::anyhow!("Invalid AES-256 key length"))?;
let cipher = Aes256Gcm::new(&key);
let nonce = Nonce::try_from(&data[..12])
.map_err(|_| anyhow::anyhow!("Invalid nonce length"))?;
let plaintext = cipher
.decrypt(&nonce, &data[12..])
.map_err(|e| anyhow::anyhow!("AES-GCM decryption failed: {:?}", e))?;
Ok(plaintext)
}
+19 -21
View File
@@ -10,9 +10,9 @@ use std::str::FromStr;
use tracing::debug;
use tracing::info;
pub fn next_run_timestamp(expr: &str) -> i64 {
let schedule = Schedule::from_str(expr).unwrap();
schedule.upcoming(Local).next().unwrap().timestamp()
pub fn next_run_timestamp(expr: &str) -> Option<i64> {
let schedule = Schedule::from_str(expr).ok()?;
Some(schedule.upcoming(Local).next()?.timestamp())
}
pub async fn check_and_update_cron(
@@ -38,8 +38,9 @@ pub async fn check_and_update_cron(
}
Some(cron) => {
let cron = normalize_cron(&cron);
debug!("Task cron (normalized): {:?}", cron);
let raw_cron = cron;
let cron = normalize_cron(&raw_cron);
debug!("Task cron normalized: unix \"{}\" -> crate \"{}\"", raw_cron, cron);
if exists {
let raw: String = conn.hget(&redis_key, "data").await.unwrap();
@@ -50,24 +51,21 @@ pub async fn check_and_update_cron(
let metadata_changed = stored.metadata != metadata;
if cron_changed || args_changed || metadata_changed {
upsert_task(conn, &task_name, task, &cron, args.clone(), metadata)
.await
.unwrap_or_else(|e| {
tracing::error!("Failed to update task {}: {:?}", task_name, e);
});
info!(
"Task {} updated (cron: {}, args: {}, metadata: {})",
task_name, cron_changed, args_changed, metadata_changed
);
match upsert_task(conn, &task_name, task, &cron, args.clone(), metadata).await {
Ok(()) => info!(
"Task {} updated (cron: {}, args: {}, metadata: {})",
task_name, cron_changed, args_changed, metadata_changed
),
Err(e) => {
tracing::error!("Failed to update task {}: {:?}", task_name, e)
}
}
}
} else {
upsert_task(conn, &task_name, task, &cron, args, metadata)
.await
.unwrap_or_else(|e| {
tracing::error!("Failed to create task {}: {:?}", task_name, e);
});
info!("Task {} created", task_name);
match upsert_task(conn, &task_name, task, &cron, args, metadata).await {
Ok(()) => info!("Task {} created", task_name),
Err(e) => tracing::error!("Failed to create task {}: {:?}", task_name, e),
}
}
}
}
+11 -2
View File
@@ -48,8 +48,17 @@ pub async fn scheduler_loop(mut conn: MultiplexedConnection) {
task_clone.task, e
);
}
let next_ts = next_run_timestamp(&task_clone.cron);
let _: () = conn_clone.zadd(SCHEDULE_KEY, &key, next_ts).await.unwrap();
match next_run_timestamp(&task_clone.cron) {
Some(next_ts) => {
let _: () = conn_clone.zadd(SCHEDULE_KEY, &key, next_ts).await.unwrap();
}
None => {
error!(
"Invalid cron expression for task={}: {}",
task_clone.task, task_clone.cron
);
}
}
});
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
+10 -1
View File
@@ -16,7 +16,16 @@ pub async fn upsert_task(
metadata: Option<Value>,
) -> redis::RedisResult<()> {
let key = format!("redbeat:{}", name);
let next_ts = next_run_timestamp(cron);
let next_ts = match next_run_timestamp(cron) {
Some(ts) => ts,
None => {
return Err(redis::RedisError::from((
redis::ErrorKind::Client,
"invalid cron expression",
cron.to_string(),
)));
}
};
let entry = PeriodicTask {
task: task.to_string(),
+51 -3
View File
@@ -1,7 +1,55 @@
pub fn normalize_cron(expr: &str) -> String {
if expr.split_whitespace().count() == 5 {
format!("0 {}", expr)
let fields: Vec<&str> = expr.split_whitespace().collect();
let (sec, mut rest): (String, Vec<String>) = match fields.len() {
5 => ("0".to_string(), fields.iter().map(|s| s.to_string()).collect()),
6 => (
fields[0].to_string(),
fields[1..].iter().map(|s| s.to_string()).collect(),
),
_ => return expr.to_string(),
};
if let Some(last) = rest.last_mut() {
*last = convert_dow(last);
}
format!("{} {}", sec, rest.join(" "))
}
fn convert_dow(field: &str) -> String {
field
.split(',')
.map(convert_dow_part)
.collect::<Vec<_>>()
.join(",")
}
fn convert_dow_part(part: &str) -> String {
let (base, step) = match part.split_once('/') {
Some((b, s)) => (b, Some(s)),
None => (part, None),
};
let converted = if let Some((start, end)) = base.split_once('-') {
match (start.parse::<u8>(), end.parse::<u8>()) {
(Ok(a), Ok(b)) if a <= 7 && b <= 7 => {
format!("{}-{}", (a % 7) + 1, (b % 7) + 1)
}
_ => base.to_string(),
}
} else if let Ok(n) = base.parse::<u8>() {
if n <= 7 {
((n % 7) + 1).to_string()
} else {
base.to_string()
}
} else {
expr.to_string()
base.to_string()
};
match step {
Some(s) => format!("{}/{}", converted, s),
None => converted,
}
}