Compare commits

...

23 Commits

Author SHA1 Message Date
github-actions[bot] a2be03751f chore: release 1.4.0 2026-03-12 18:23:42 +00:00
Charles GTE 7df9df1605 feat : test coverage for utils functions #35
feat : test coverage for utils functions
2026-03-12 19:21:09 +01:00
charlesgauthereau df5b16a153 fix: docker-compose.yml 2026-03-12 19:16:12 +01:00
charlesgauthereau 824a5d52a3 feat: add test for util functions and start working on database providers tests procedures 2026-03-12 18:56:07 +01:00
charlesgauthereau 1496208db4 Merge branch 'main' into dev 2026-03-09 16:46:00 +01:00
charlesgauthereau 454e1b442f Merge remote-tracking branch 'origin/main' 2026-03-09 16:45:52 +01:00
github-actions[bot] 54bca7452a chore: release 1.3.0 2026-03-09 15:43:44 +00:00
Charles GTE a96cef6cb5 feat: helm chart (#34)
* fix: docker.yml

* feat: add helm chart config

* fix: helm config

* feat: add .github workflow for helm chart

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-09 16:41:14 +01:00
charlesgauthereau fd9f183166 Merge branch 'main' into dev 2026-03-08 22:02:36 +01:00
charlesgauthereau 94fb2535ec Merge remote-tracking branch 'origin/main' 2026-03-08 21:59:02 +01:00
github-actions[bot] 189ad866de chore: release 1.2.5 2026-03-08 20:50:53 +00:00
Charles GTE 55bbd4e724 fix: new-workflow-ci (#32)
* feat: new workflow CI

* feat: new workflow CI

* feat: new workflow CI

* feat: new workflow CI

* feat: new workflow CI

* feat: new workflow CI

* fix: docker.yml

* fix: docker.yml

* fix: docker.yml

* fix: docker.yml

* fix: docker.yml

* fix: docker.yml

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-08 21:48:13 +01:00
charlesgauthereau 7fabc9c98c Merge remote-tracking branch 'origin/main' 2026-03-08 21:47:15 +01:00
github-actions[bot] 4f67a57681 chore: release 1.2.4 2026-03-08 20:20:58 +00:00
Charles GTE 45a1118f64 fix: docker build (#31)
* feat: new workflow CI

* feat: new workflow CI

* feat: new workflow CI

* feat: new workflow CI

* feat: new workflow CI

* feat: new workflow CI

* fix: docker.yml

* fix: docker.yml

* fix: docker.yml

* fix: docker.yml

* fix: docker.yml

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-08 21:18:27 +01:00
charlesgauthereau 063b2e1c2c Merge remote-tracking branch 'origin/main' 2026-03-08 21:12:09 +01:00
github-actions[bot] f2275d5aca chore: release 1.2.3 2026-03-08 20:00:47 +00:00
Charles GTE b136140b55 fix: new-workflow-ci (#30)
* feat: new workflow CI

* feat: new workflow CI

* feat: new workflow CI

* feat: new workflow CI

* feat: new workflow CI

* feat: new workflow CI

* fix: docker.yml

* fix: docker.yml

* fix: docker.yml

* fix: docker.yml

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-08 20:58:19 +01:00
charlesgauthereau 924177a4fe Merge remote-tracking branch 'origin/main' 2026-03-08 20:52:36 +01:00
charlesgauthereau 678df8e2bc fix: docker.yml 2026-03-08 20:46:47 +01:00
charlesgauthereau 7e0c7c0688 Merge branch 'main' into dev
# Conflicts:
#	docker-compose.yml
2026-03-08 19:48:00 +01:00
charlesgauthereau b9b9fcf6ee Merge branch 'main' into dev 2026-03-08 10:02:34 +01:00
charlesgauthereau 0224605c59 fix: docker-compose.yml 2026-03-08 10:02:17 +01:00
29 changed files with 1607 additions and 692 deletions
+7 -4
View File
@@ -6,6 +6,9 @@ on:
version:
required: true
type: string
ref:
required: true
type: string
image_name:
required: false
type: string
@@ -37,8 +40,10 @@ jobs:
matrix:
platform: [ linux/amd64, linux/arm64 ]
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -66,8 +71,6 @@ jobs:
push: true
tags: ${{ steps.prep.outputs.image }}
target: ${{ inputs.target }}
cache-from: type=gha,scope=build-${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }}
- name: Save image name for manifest
run: echo "${{ steps.prep.outputs.image }}" > image.txt
+37
View File
@@ -0,0 +1,37 @@
name: Publish Helm Chart
on:
workflow_call:
inputs:
version:
required: true
type: string
secrets:
GH_TOKEN:
required: true
jobs:
publish-helm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Helm
uses: azure/setup-helm@v4
- name: Package Helm chart
run: |
mkdir -p ./helm-packages
helm package helm \
--version ${{ inputs.version }} \
--app-version ${{ inputs.version }} \
--destination ./helm-packages
- name: Authenticate to GitHub Packages
run: |
echo "${{ secrets.GH_TOKEN }}" | helm registry login ghcr.io -u ${{ github.actor }} --password-stdin
- name: Push Helm chart to GitHub Packages (OCI)
run: |
helm push ./helm-packages/portabase-agent-${{ inputs.version }}.tgz oci://ghcr.io/portabase/charts
+17
View File
@@ -50,6 +50,12 @@ jobs:
token: ${{ steps.app-token.outputs.token }}
ref: main
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Install cargo-edit
run: cargo install cargo-edit
- uses: actions/setup-node@v4
with:
node-version: "lts/*"
@@ -88,16 +94,26 @@ jobs:
uses: ./.github/workflows/docker.yml
with:
version: ${{ needs.create-release.outputs.version }}
ref: ${{ needs.create-release.outputs.version }}
add_latest: true
secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
publish-helm:
needs: create-release
if: ${{ needs.create-release.result == 'success' }}
uses: ./.github/workflows/helm.yml
with:
version: ${{ needs.create-release.outputs.version }}
secrets:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
finalize-release:
needs:
- create-release
- publish-docker
- publish-helm
runs-on: ubuntu-latest
outputs:
release_tag: ${{ steps.publish_release_step.outputs.release_tag }}
@@ -119,6 +135,7 @@ jobs:
notify-discord:
needs:
- publish-docker
- publish-helm
- create-release
- finalize-release
uses: ./.github/workflows/discord.yml
+3 -5
View File
@@ -12,6 +12,9 @@
"tagName": "${version}",
"push": true
},
"hooks": {
"before:bump": "cargo set-version ${version}"
},
"plugins": {
"@release-it/conventional-changelog": {
"preset": {
@@ -48,11 +51,6 @@
},
"@release-it/bumper": {
"out": [
{
"file": "Cargo.toml",
"path": "package.version",
"type": "toml"
},
{
"file": "CITATION.cff",
"path": "version",
+1 -1
View File
@@ -27,5 +27,5 @@ keywords:
- self-hosted
- portabase
license: Apache-2.0
version: 1.2.2
version: 1.4.0
date-released: '2026-02-24'
Generated
+879 -669
View File
File diff suppressed because it is too large Load Diff
+11 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "portabase-agent"
version = "1.2.2"
version = "1.4.0"
edition = "2024"
[dependencies]
@@ -37,18 +37,26 @@ async-stream = "0.3.6"
uuid = { version = "1.20.0", features = ["v4"] }
tokio-util = "0.7.18"
aws-config = "1.8.13"
aws-sdk-s3 = { version = "1.1.2.2", features = ["behavior-version-latest"] }
aws-sdk-s3 = { version = "1.122.0", features = ["behavior-version-latest"] }
async-compression = { version = "0.4.37", features = ["tokio", "gzip"] }
tokio-tar = "0.3.1"
oauth2 = "5.0.0"
hyper = "1.8.1"
async-http-client = "0.2.0"
aes-gcm = "0.11.0-rc.3"
generic-array = "0.14.7"
futures-util = "0.3.31"
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"] }
postgres = "0.19.12"
[dev-dependencies]
tokio = { version = "1", features = ["full"] }
mockall = "0.13"
testcontainers = "0.27.1"
wiremock = "0.6"
[[bin]]
+4
View File
@@ -1,8 +1,12 @@
include .env
export $(shell sed 's/=.*//' .env)
CLUSTER_SCRIPT=docker/entrypoints/app-dev-entrypoint.sh
.PHONY: seed-mongo seed-mysql seed-postgres
up:
@bash $(CLUSTER_SCRIPT)
seed-mongo:
@echo "Seeding MongoDB..."
bash ./scripts/mongo/seed-mongo.sh
+3 -4
View File
@@ -11,14 +11,14 @@ services:
# - ./databases.toml:/config/config.toml
- cargo-registry:/usr/local/cargo/registry
- cargo-git:/usr/local/cargo/git
# - cargo-target:/app/target
- cargo-target:/app/target
# - sqlite-data:/sqlite-data/workspace/data
# - ./scripts/sqlite/test-db:/sqlite-data-2/workspace/data
environment:
APP_ENV: development
LOG: debug
TZ: "Europe/Paris"
EDGE_KEY: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiNjI1MDQzY2YtN2MwMC00M2M4LWJjYzktZDM1MTk5ODk2ZGNkIiwibWFzdGVyS2V5QjY0IjoiQlhWM1hvbEM2NTZTVjdkTmdjV1BHUWxrKytycExJNmxHRGk3Q1BCNWllbz0ifQ=="
EDGE_KEY: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiOGYwMmExZTAtNDY0NC00MWFmLWIzYjctYjZkYWNjNzQ4OWVhIiwibWFzdGVyS2V5QjY0IjoiQlhWM1hvbEM2NTZTVjdkTmdjV1BHUWxrKytycExJNmxHRGk3Q1BCNWllbz0ifQ=="
#POOLING: 1
#DATABASES_CONFIG_FILE: "config.toml"
extra_hosts:
@@ -107,7 +107,7 @@ services:
volumes:
cargo-registry:
cargo-git:
# cargo-target:
cargo-target:
postgres-data:
# mariadb-data:
@@ -120,4 +120,3 @@ networks:
name: portabase_network
external: true
# docker network create portabase_network
+39
View File
@@ -0,0 +1,39 @@
#!/bin/bash
set -euo pipefail
check_docker() {
if ! docker info > /dev/null 2>&1; then
echo "Docker is not running. Attempting to start Docker..."
if [[ "$OSTYPE" == "darwin"* ]]; then
open -a Docker
echo "Waiting for Docker to start..."
until docker info > /dev/null 2>&1; do
sleep 2
done
elif command -v systemctl >/dev/null 2>&1; then
sudo systemctl start docker
else
echo "Cannot start Docker automatically. Please start Docker manually."
exit 1
fi
else
echo "Docker is running."
fi
}
check_network() {
local network_name="portabase_network"
if ! docker network ls --format '{{.Name}}' | grep -q "^${network_name}$"; then
echo "Docker network '${network_name}' not found. Creating..."
docker network create "${network_name}"
else
echo "Docker network '${network_name}' already exists."
fi
}
check_docker
check_network
echo "Starting docker-compose..."
docker-compose -f ./docker-compose.yml up
echo "Docker-compose started successfully."
+17
View File
@@ -0,0 +1,17 @@
.DS_Store
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
*.swp
*.bak
*.tmp
*.orig
*~
.project
.idea/
*.tmproj
.vscode/
+29
View File
@@ -0,0 +1,29 @@
apiVersion: v2
name: portabase-agent
description: Helm chart for Portabase Agent
type: application
version: 0.0.0
appVersion: "latest"
keywords:
- postgresql
- mariadb
- mongodb
- mysql
- sqlite
- backup
- database
- restore
- agent
home: https://github.com/Portabase/agent
sources:
- https://github.com/Portabase/agent
- https://github.com/Portabase/agent/tree/main/helm
maintainers:
- name: Charles Gauthereau
url: https://github.com/RambokDev
- name: Killian Larcher
url: https://github.com/KillianLarcher
icon: https://raw.githubusercontent.com/Portabase/agent/main/.github/assets/logo.png
+59
View File
@@ -0,0 +1,59 @@
# Development Notes
## Check that Kubernetes is reachable locally
```bash
kubectl get nodes
```
## Install the local Portabase Agent Helm chart
```bash
helm install portabase-agent . \
--set env.EDGE_KEY=<your-edge-key>
```
## Check the pods
```bash
kubectl get pods
```
## Check the services
```bash
kubectl get svc
```
## To update .env variables or JSON config:
```bash
kubectl rollout restart deployment portabase-agent
```
## Install or upgrade the Helm chart
```bash
helm upgrade portabase-agent . \
--reuse-values \
--set env.EDGE_KEY="NEW_EDGE_KEY"
```
## Rollout to restart
```bash
kubectl rollout restart deployment portabase-agent
```
## List pods to get the pod name
```bash
kubectl get pods -l app=portabase-agent
```
## Get logs for the pod
```bash
kubectl logs portabase-agent-6f7d4f5c6b-abc12
```
## Uninstall Agent
``` bash
helm uninstall portabase-agent
```
+7
View File
@@ -0,0 +1,7 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: portabase-agent-config
data:
config.json: |
{{ .Values.volume.configFile.content | nindent 4 }}
+62
View File
@@ -0,0 +1,62 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: portabase-agent
labels:
app: portabase-agent
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
app: portabase-agent
template:
metadata:
labels:
app: portabase-agent
spec:
hostAliases:
{{- range .Values.network.hostAliases }}
- ip: {{ .ip }}
hostnames:
{{- range .hostnames }}
- {{ . }}
{{- end }}
{{- end }}
containers:
- name: portabase-agent
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
envFrom:
- configMapRef:
name: portabase-agent-env
volumeMounts:
{{- if .Values.volume.configFile.enabled }}
{{- if .Values.volume.configFile.hostPath }}
- name: config
mountPath: /config/config.json
subPath: config.json
readOnly: true
# uses hostPath
{{- else }}
- name: config
mountPath: /config/config.json
subPath: config.json
# uses ConfigMap content
{{- end }}
{{- end }}
volumes:
{{- if .Values.volume.configFile.enabled }}
{{- if .Values.volume.configFile.hostPath }}
- name: config
hostPath:
path: {{ .Values.volume.configFile.hostPath }}
type: File
{{- else }}
- name: config
configMap:
name: portabase-agent-config
items:
- key: config.json
path: config.json
{{- end }}
{{- end }}
+10
View File
@@ -0,0 +1,10 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: portabase-agent-env
data:
EDGE_KEY: {{ .Values.env.EDGE_KEY | quote }}
TZ: {{ .Values.env.TZ | quote }}
POLLING: {{ .Values.env.POLLING | quote }}
APP_ENV: {{ .Values.env.APP_ENV | quote }}
LOG: {{ .Values.env.LOG | quote }}
+48
View File
@@ -0,0 +1,48 @@
replicaCount: 1
image:
repository: portabase/agent
tag: latest
pullPolicy: IfNotPresent
env:
EDGE_KEY: "your_edge_key_here"
TZ: "UTC"
POLLING: "5"
APP_ENV: "production"
LOG: "info"
resources:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 250m
memory: 256Mi
volume:
configFile:
enabled: true
hostPath: "" # Use host file if set, otherwise use `content`
content: | # JSON content for config.json if no hostPath
{
"databases": [
{
"name": "my-site-prod (readable name)",
"database": "devdb",
"type": "postgresql",
"host": "localhost",
"port": 5432,
"username": "admin_prod",
"password": "super_secure_password",
"generated_id": "550e8400-e29b-41d4-a716-446655440000"
}
]
}
network:
hostAliases:
- ip: "127.0.0.1"
hostnames:
- "localhost"
+2
View File
@@ -4,6 +4,8 @@ mod services;
mod settings;
mod tasks;
mod utils;
#[cfg(test)]
mod tests;
use crate::tasks::ping::ping_server;
use crate::utils::locks::FileLock;
+1
View File
@@ -0,0 +1 @@
mod postgres;
+35
View File
@@ -0,0 +1,35 @@
use oauth2::url;
use testcontainers::runners::AsyncRunner;
use testcontainers_modules::postgres::Postgres;
use crate::services::config::{DatabaseConfig, DbType};
use url::Host;
use crate::domain::factory::DatabaseFactory;
#[tokio::test]
async fn postgres_ping_test() {
let container = Postgres::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(5432).await.unwrap_or(5432) ;
let config = DatabaseConfig {
name: "My test Postgres Database".to_string(),
database: "postgres".to_string(),
db_type: DbType::Postgresql,
username: "postgres".to_string(),
password: "postgres".to_string(),
port,
host: host.to_string(),
generated_id: "40875631-e3d2-4dfe-a26b-2a347ecc64fd".to_string(),
path: "".to_string(),
};
let db = DatabaseFactory::create_for_backup(config.clone()).await;
let reachable = db.ping().await.unwrap_or_else(|_| false);
assert_eq!(reachable, true);
}
+2
View File
@@ -0,0 +1,2 @@
mod utils;
mod domain;
+46
View File
@@ -0,0 +1,46 @@
use serde_json::json;
use crate::utils::common::{vec_to_option_json, BackupMethod};
#[test]
fn backup_method_to_string_automatic() {
let method = BackupMethod::Automatic;
assert_eq!(method.to_string(), "automatic");
}
#[test]
fn backup_method_to_string_manual() {
let method = BackupMethod::Manual;
assert_eq!(method.to_string(), "manual");
}
#[test]
fn vec_to_option_json_returns_none_when_empty() {
let v: Vec<i32> = vec![];
let result = vec_to_option_json(v);
assert!(result.is_none());
}
#[test]
fn vec_to_option_json_serializes_vector() {
let v = vec![1, 2, 3];
let result = vec_to_option_json(v);
assert_eq!(result, Some(json!([1, 2, 3])));
}
#[test]
fn vec_to_option_json_serializes_struct_vector() {
#[derive(serde::Serialize)]
struct Item {
id: u32,
}
let v = vec![Item { id: 1 }, Item { id: 2 }];
let result = vec_to_option_json(v);
assert_eq!(result, Some(json!([
{ "id": 1 },
{ "id": 2 }
])));
}
+73
View File
@@ -0,0 +1,73 @@
use tempfile::tempdir;
use tokio::fs::{write, read};
use anyhow::Result;
use crate::utils::compress::{compress_to_tar_gz_large, decompress_large_tar_gz};
#[tokio::test]
async fn compress_creates_tar_gz() -> Result<()> {
let tmp = tempdir()?;
let file_path = tmp.path().join("test.txt");
write(&file_path, b"hello world").await?;
let result = compress_to_tar_gz_large(&file_path).await?;
assert!(result.compressed_path.exists());
assert_eq!(result.compressed_path.extension().unwrap(), "gz");
Ok(())
}
#[tokio::test]
async fn compress_skips_existing_tar_gz() -> Result<()> {
let tmp = tempdir()?;
let file_path = tmp.path().join("already.tar.gz");
write(&file_path, b"compressed").await?;
let result = compress_to_tar_gz_large(&file_path).await?;
// Should return same path without creating a new file
assert_eq!(result.compressed_path, file_path);
Ok(())
}
#[tokio::test]
async fn decompress_restores_file() -> Result<()> {
let tmp = tempdir()?;
let file_path = tmp.path().join("file.txt");
write(&file_path, b"data for decompress").await?;
let compress_result = compress_to_tar_gz_large(&file_path).await?;
let output_dir = tmp.path().join("out");
tokio::fs::create_dir_all(&output_dir).await?;
let extracted_files = decompress_large_tar_gz(&compress_result.compressed_path, &output_dir).await?;
assert_eq!(extracted_files.len(), 1);
let extracted_content = read(&extracted_files[0]).await?;
assert_eq!(extracted_content, b"data for decompress");
Ok(())
}
#[tokio::test]
async fn decompress_multiple_files() -> Result<()> {
let tmp = tempdir()?;
let file1 = tmp.path().join("file1.txt");
let file2 = tmp.path().join("file2.txt");
write(&file1, b"file1").await?;
write(&file2, b"file2").await?;
// Compress both files individually (for simplicity in this test)
let compress1 = compress_to_tar_gz_large(&file1).await?;
let compress2 = compress_to_tar_gz_large(&file2).await?;
let output_dir = tmp.path().join("out_multi");
tokio::fs::create_dir_all(&output_dir).await?;
let extracted1 = decompress_large_tar_gz(&compress1.compressed_path, &output_dir).await?;
let extracted2 = decompress_large_tar_gz(&compress2.compressed_path, &output_dir).await?;
assert_eq!(extracted1.len(), 1);
assert_eq!(extracted2.len(), 1);
Ok(())
}
+104
View File
@@ -0,0 +1,104 @@
#[cfg(test)]
mod tests {
use serde::Deserialize;
use toml::map::Map;
use toml::Value;
use crate::utils::deserializer::{camel_to_snake, deserialize_snake_case, to_snake_case};
#[test]
fn camel_to_snake_simple() {
assert_eq!(camel_to_snake("CamelCase"), "camel_case");
assert_eq!(camel_to_snake("simpleTest"), "simple_test");
assert_eq!(camel_to_snake("already_snake"), "already_snake");
assert_eq!(camel_to_snake("X"), "x");
assert_eq!(camel_to_snake("ABTest"), "a_b_test");
}
#[test]
fn to_snake_case_nested_table() {
let mut inner_table = Map::new();
inner_table.insert("InnerKey".into(), Value::String("value".into()));
let mut outer_table = Map::new();
outer_table.insert("OuterKey".into(), Value::Table(inner_table));
let value = Value::Table(outer_table);
// Expected snake_case
let mut expected_inner = Map::new();
expected_inner.insert("inner_key".into(), Value::String("value".into()));
let mut expected_outer = Map::new();
expected_outer.insert("outer_key".into(), Value::Table(expected_inner));
let expected = Value::Table(expected_outer);
let result = to_snake_case(value);
assert_eq!(result, expected);
}
#[test]
fn to_snake_case_array_of_tables() {
let mut table1 = Map::new();
table1.insert("CamelKey".into(), Value::Integer(1));
let mut table2 = Map::new();
table2.insert("AnotherKey".into(), Value::Integer(2));
let value = Value::Array(vec![
Value::Table(table1),
Value::Table(table2),
]);
let mut expected_table1 = Map::new();
expected_table1.insert("camel_key".into(), Value::Integer(1));
let mut expected_table2 = Map::new();
expected_table2.insert("another_key".into(), Value::Integer(2));
let expected = Value::Array(vec![
Value::Table(expected_table1),
Value::Table(expected_table2),
]);
let result = to_snake_case(value);
assert_eq!(result, expected);
}
#[test]
fn deserialize_snake_case_works_with_struct() {
#[derive(Deserialize, Debug, PartialEq)]
struct Config {
some_value: i32,
nested_table: Nested,
}
#[derive(Deserialize, Debug, PartialEq)]
struct Nested {
inner_value: String,
}
let toml_str = r#"
SomeValue = 42
[NestedTable]
InnerValue = "hello"
"#;
let value: Value = toml::from_str(toml_str).unwrap();
let snake_value = deserialize_snake_case(value).unwrap();
// Deserialize to struct
let config: Config = snake_value.try_into().unwrap();
assert_eq!(config.some_value, 42);
assert_eq!(config.nested_table.inner_value, "hello");
}
#[test]
fn to_snake_case_non_table_value() {
let value = Value::String("unchanged".into());
let result = to_snake_case(value.clone());
assert_eq!(result, value);
}
}
+53
View File
@@ -0,0 +1,53 @@
use base64::{engine::general_purpose, Engine as _};
use serde_json::json;
use crate::utils::edge_key::{decode_edge_key, EdgeKeyError};
#[test]
fn decode_valid_edge_key() {
let edge_key_b64 = "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiNjI1MDQzY2YtN2MwMC00M2M4LWJjYzktZDM1MTk5ODk2ZGNkIiwibWFzdGVyS2V5QjY0IjoiQlhWM1hvbEM2NTZTVjdkTmdjV1BHUWxrKytycExJNmxHRGk3Q1BCNWllbz0ifQ==";
let decoded = decode_edge_key(edge_key_b64).unwrap();
assert_eq!(decoded.server_url, "http://localhost:8887");
assert_eq!(decoded.agent_id, "625043cf-7c00-43c8-bcc9-d35199896dcd");
assert_eq!(decoded.master_key_b64, "BXV3XolC656SV7dNgcWPGQlk++rpLI6lGDi7CPB5ieo=");
}
#[test]
fn decode_edge_key_missing_field() {
let incomplete_json = json!({
"serverUrl": "http://localhost:8887",
"agentId": "123"
// masterKeyB64 is missing
})
.to_string();
let b64 = general_purpose::URL_SAFE.encode(incomplete_json);
let result = decode_edge_key(&b64);
match result {
Err(EdgeKeyError::InvalidKey) => {}
_ => panic!("Expected InvalidKey error"),
}
}
#[test]
fn decode_edge_key_invalid_base64() {
let invalid_b64 = "!!!notbase64!!!";
let result = decode_edge_key(invalid_b64);
match result {
Err(EdgeKeyError::Base64Error(_)) => {}
_ => panic!("Expected Base64Error"),
}
}
#[test]
fn decode_edge_key_invalid_json() {
let invalid_json_b64 = general_purpose::URL_SAFE.encode("not a json string");
let result = decode_edge_key(&invalid_json_b64);
match result {
Err(EdgeKeyError::JsonError(_)) => {}
_ => panic!("Expected JsonError"),
}
}
+5
View File
@@ -0,0 +1,5 @@
mod normalize_cron_tests;
mod common_tests;
mod compress_tests;
mod deserializer;
mod edge_key_tests;
+49
View File
@@ -0,0 +1,49 @@
use crate::utils::text::normalize_cron;
use cron::Schedule;
use std::str::FromStr;
use crate::utils::task_manager::cron::next_run_timestamp;
#[test]
fn normalize_adds_seconds_to_five_field_cron() {
let input = "*/5 * * * *";
let normalized = normalize_cron(input);
assert_eq!(normalized, "0 */5 * * * *");
}
#[test]
fn normalize_keeps_six_field_cron() {
let input = "0 */5 * * * *";
let normalized = normalize_cron(input);
assert_eq!(normalized, "0 */5 * * * *");
}
#[test]
fn normalized_expression_is_valid_for_cron_schedule() {
let input = "*/5 * * * *";
let normalized = normalize_cron(input);
let schedule = Schedule::from_str(&normalized);
assert!(schedule.is_ok());
}
#[test]
fn next_run_timestamp_returns_future_timestamp() {
let expr = normalize_cron("*/1 * * * *");
let ts = next_run_timestamp(&expr);
let now = chrono::Local::now().timestamp();
assert!(ts > now);
}
#[test]
fn normalization_does_not_break_schedule_parsing() {
let input = "0 */10 * * * *";
let normalized = normalize_cron(input);
let schedule = Schedule::from_str(&normalized).unwrap();
let next = schedule.upcoming(chrono::Local).next();
assert!(next.is_some());
}
-1
View File
@@ -90,7 +90,6 @@ pub async fn decompress_large_tar_gz(
extracted_files.push(full_path);
}
// remove_file(tar_gz_path).await?;
info!("Decompressed {:?} into {:?}", tar_gz_path, output_dir);
Ok(extracted_files)
+4 -5
View File
@@ -8,7 +8,8 @@ where
let value = Value::deserialize(deserializer)?;
Ok(to_snake_case(value))
}
fn to_snake_case(value: Value) -> Value {
pub fn to_snake_case(value: Value) -> Value {
match value {
Value::Table(table) => Value::Table(
table
@@ -16,14 +17,12 @@ fn to_snake_case(value: Value) -> Value {
.map(|(k, v)| (camel_to_snake(&k), to_snake_case(v)))
.collect(),
),
Value::Array(arr) => {
Value::Array(arr.into_iter().map(to_snake_case).collect())
}
Value::Array(arr) => Value::Array(arr.into_iter().map(to_snake_case).collect()),
other => other,
}
}
fn camel_to_snake(s: &str) -> String {
pub fn camel_to_snake(s: &str) -> String {
let mut out = String::new();
for (i, c) in s.chars().enumerate() {
if c.is_uppercase() {