Compare commits

..

60 Commits

Author SHA1 Message Date
github-actions[bot] 8e8ebb5920 chore: release 1.6.2 2026-03-21 07:53:35 +00:00
Charles GTE 8489a6f6e3 fix: adding retry config for s3 provider (#46) 2026-03-21 08:51:15 +01:00
github-actions[bot] 3f5b9c91cc chore: release 1.6.1 2026-03-18 18:42:23 +00:00
Charles GTE 1c52f298d2 fix: Enable Link-Time Optimization (LTO) and codegen-units = 1 for Release builds (#45) 2026-03-18 19:39:49 +01:00
github-actions[bot] e602c3540f chore: release 1.6.0 2026-03-16 13:38:02 +00:00
Charles GTE b5e8ccac1f feat: add-valkey #43
feat: add-valkey
2026-03-16 14:35:31 +01:00
charles-gauthereau f14a6dc318 fix: docker-compose.yml 2026-03-16 14:32:33 +01:00
charles-gauthereau e1bc77e481 fix: valkey.rs 2026-03-16 14:31:11 +01:00
charles-gauthereau cbc7517603 fix: README.md with Valkey content 2026-03-16 14:28:24 +01:00
charles-gauthereau fc4e4e3881 fix: log in backup.rs 2026-03-16 12:32:57 +01:00
charles-gauthereau 5ec7bc9f28 fix: docker-compose.yml volume for valkey 2026-03-16 12:32:12 +01:00
charles-gauthereau 45c33dd031 feat: add valkey test sequences 2026-03-16 12:30:22 +01:00
charles-gauthereau cddb9d78fa fix: entrypoint.sh 2026-03-16 12:16:03 +01:00
charles-gauthereau cf5de3115c feat: add valkey database engine for backup 2026-03-16 12:11:01 +01:00
charles-gauthereau d6d2ef18f8 fix: internal redis port 2026-03-16 11:24:30 +01:00
github-actions[bot] 987c630c73 chore: release 1.5.1 2026-03-16 07:59:25 +00:00
Charles GTE e9147d6c60 fix: s3 port type mismatch, add string_or_number_to_string deserializer (#42)
Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-16 08:57:00 +01:00
github-actions[bot] 174ead72a3 chore: release 1.5.0 2026-03-15 11:06:29 +00:00
Charles GTE f2a67b3490 feat: add-redis #39
feat: add-redis
2026-03-15 12:04:05 +01:00
charlesgauthereau d4afefbc1e fix: requirements.sh for tests tools 2026-03-14 20:08:26 +01:00
charlesgauthereau be6e147df2 fix: requirements.sh for tests tools 2026-03-14 19:54:15 +01:00
charlesgauthereau 91b6658852 fix: requirements.sh for tests tools 2026-03-14 19:40:01 +01:00
charlesgauthereau f7dcb6b2bb fix: requirements.sh for tests tools 2026-03-14 18:26:26 +01:00
charlesgauthereau ab14f0dcf0 fix: requirements.sh for tests tools 2026-03-14 18:17:43 +01:00
charlesgauthereau 58a0b92710 fix: pipeline codecov.yml 2026-03-14 18:05:29 +01:00
charlesgauthereau 2f24291c0e fix: pipeline codecov.yml 2026-03-14 18:02:06 +01:00
charlesgauthereau 1fdea31430 fix: postgres and redis tests 2026-03-14 17:59:44 +01:00
charlesgauthereau d1d633fd00 feat: add redis database support for backup. 2026-03-13 22:44:46 +01:00
Charles GTE b1c14f098b fix: codecov.yml (#38)
* feat: add codecov.yml

* fix: add codecov.yml

* fix: codecov.yml

---------

Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-13 16:58:51 +01:00
github-actions[bot] 3abb955e24 chore: release 1.4.1 2026-03-13 15:19:42 +00:00
Charles GTE 6d22be9b50 fix: add codecov.yml (#37) [skip-release]
Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-13 16:17:09 +01:00
Charles GTE c2df4ba71e fix: readme badge [skip-release] #36
fix: readme badge  [skip-release]
2026-03-13 15:57:16 +01:00
charles-gauthereau 46b3f2466a fix: readme badge 2026-03-13 13:50:30 +01:00
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
github-actions[bot] 773077f66b chore: release 1.2.2 2026-03-08 19:48:25 +00:00
Charles GTE 46ff086cf9 fix: new-workflow-ci (#29)
* 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

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-08 20:47:41 +01:00
charlesgauthereau 678df8e2bc fix: docker.yml 2026-03-08 20:46:47 +01:00
github-actions[bot] f563c187db chore: release 1.2.1 2026-03-08 19:43:01 +00:00
Charles GTE df0dffa47b fix: new-workflow-ci (#28)
* feat: new workflow CI

* feat: new workflow CI

* feat: new workflow CI

* feat: new workflow CI

* feat: new workflow CI

* feat: new workflow CI

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-08 20:42:16 +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
62 changed files with 2669 additions and 888 deletions
+58
View File
@@ -0,0 +1,58 @@
name: Codecov Rust
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
components: llvm-tools-preview
- name: Install grcov
run: cargo install grcov
- name: Install test requirements
run: bash scripts/tests/requirements.sh
- name: Build
run: cargo build --verbose
- name: Run tests
env:
CARGO_INCREMENTAL: 0
RUSTFLAGS: "-C instrument-coverage"
LLVM_PROFILE_FILE: "cargo-test-%p-%m.profraw"
run: cargo test --verbose
- name: Generate coverage
run: |
grcov . \
--binary-path ./target/debug/ \
-s . \
-t lcov \
--branch \
--ignore-not-existing \
-o lcov.info
- name: Upload to Codecov
uses: codecov/codecov-action@v5
with:
files: lcov.info
verbose: true
fail_ci_if_error: true
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
+8 -5
View File
@@ -6,6 +6,9 @@ on:
version:
required: true
type: string
ref:
required: true
type: string
image_name:
required: false
type: string
@@ -21,7 +24,7 @@ on:
dockerfile:
required: false
type: string
default: "./docker/dockerfile/Dockerfile"
default: "./docker/Dockerfile"
secrets:
DOCKER_USERNAME:
required: true
@@ -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
+24 -26
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/*"
@@ -64,59 +70,50 @@ jobs:
git config --global user.name 'github-actions[bot]'
git config --global user.email 'github-actions[bot]@users.noreply.github.com'
# - name: Run release-it
# id: release_step
# run: |
# git pull origin main
#
# VERSION=$(release-it --ci --release-version)
# echo $VERSION
# echo "version=$VERSION" >> $GITHUB_OUTPUT
#
# OUTPUT=$(release-it --ci)
# echo "$OUTPUT"
#
# DRAFT_TAG=$(echo "$OUTPUT" | grep -oE 'untagged-[a-z0-9]+')
# echo $DRAFT_TAG
# echo "draft_tag=$DRAFT_TAG" >> $GITHUB_OUTPUT
# env:
# GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
- name: Run release-it
id: release_step
run: |
git pull origin main
# Run release-it in CI mode (bump version, commit, tag, changelog, GitHub release)
VERSION=$(release-it --ci --release-version)
echo $VERSION
echo "version=$VERSION" >> $GITHUB_OUTPUT
OUTPUT=$(release-it --ci)
echo "$OUTPUT"
# Extract the new version
VERSION=$(echo "$OUTPUT" | grep -oP '(?<=Bumping version: ).*')
echo "version=$VERSION" >> $GITHUB_OUTPUT
# Extract draft tag if any
DRAFT_TAG=$(echo "$OUTPUT" | grep -oE 'untagged-[a-z0-9]+')
echo $DRAFT_TAG
echo "draft_tag=$DRAFT_TAG" >> $GITHUB_OUTPUT
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
publish-docker:
needs: create-release
if: ${{ needs.create-release.result == 'success' }}
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 }}
@@ -138,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.0
version: 1.6.2
date-released: '2026-02-24'
Generated
+918 -708
View File
File diff suppressed because it is too large Load Diff
+17 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "portabase-agent"
version = "1.2.0"
version = "1.6.2"
edition = "2024"
[dependencies]
@@ -30,7 +30,7 @@ tokio-postgres = "0.7.15"
futures = "0.3.31"
tracing-appender = "0.2.4"
time = { version = "0.3.44", features = ["macros"] }
mongodb = "3.5.0"
mongodb = "3.5.1"
rand = "0.9.2"
bytes = "1.11.0"
async-stream = "0.3.6"
@@ -42,14 +42,28 @@ 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", "redis", "valkey"] }
postgres = "0.19.12"
url = "2.5.8"
[dev-dependencies]
tokio = { version = "1", features = ["full"] }
mockall = "0.13"
testcontainers = "0.27.1"
testcontainers-modules = { version = "0.15.0", features = ["postgres", "redis"] }
wiremock = "0.6"
[profile.release]
lto = true
codegen-units = 1
strip = "debuginfo"
[[bin]]
name = "app"
+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
+2 -1
View File
@@ -16,10 +16,11 @@
[![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20windows-lightgrey)](https://github.com/Portabase/portabase)
[![Support Portabase](https://img.shields.io/badge/Support-Portabase-orange)](https://www.buymeacoffee.com/portabase)
[![PostgreSQL](https://img.shields.io/badge/python-3.13-blue.svg)](https://www.python.org/downloads/release/python-3120/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-336791?logo=postgresql&logoColor=white)](https://www.postgresql.org/)
[![MySQL](https://img.shields.io/badge/MySQL-4479A1?logo=mysql&logoColor=white)](https://www.mysql.com/)
[![SQLite](https://img.shields.io/badge/-SQLite-blue?logo=sqlite&logoColor=white)](https://sqlite.org/)
[![Redis](https://img.shields.io/badge/Redis-DC382D?style=flat&logo=Redis&logoColor=white)](https://redis.io/)
[![Valkey](https://img.shields.io/badge/Valkey-6284fc?style=flat&logo=Valkey&logoColor=white)](https://valkey.io/)
[![MariaDB](https://img.shields.io/badge/MariaDB-003545?logo=mariadb&logoColor=white)](https://mariadb.org/)
[![MongoDB](https://img.shields.io/badge/-MongoDB-13aa52?logo=mongodb&logoColor=white)](https://www.mongodb.com/)
[![Self Hosted](https://img.shields.io/badge/self--hosted-yes-brightgreen)](https://github.com/Portabase/portabase)
+32
View File
@@ -49,6 +49,38 @@
"type": "sqlite",
"path": "/sqlite-data-2/workspace/data/app.db",
"generated_id": "16678179-ff7e-4c97-8c83-0adeff214681"
},
{
"name": "Test database 8 - Redis",
"type": "redis",
"port": 6379,
"host": "db-redis",
"generated_id": "16678166-ff7e-4c97-8c83-0adeff214681"
},
{
"name": "Test database 9 - Redis Auth",
"type": "redis",
"password": "supersecurepassword",
"port": 6379,
"username": "default",
"host": "db-redis-auth",
"generated_id": "16678160-ff7e-4c97-8c83-0adeff214681"
},
{
"name": "Test database 10 - Valkey",
"type": "valkey",
"port": 6379,
"host": "db-valkey",
"generated_id": "16678560-ff7e-4c97-8c83-0adeff214681"
},
{
"name": "Test database 11 - Valkey Auth",
"type": "valkey",
"password": "supersecurepassword",
"port": 6379,
"username": "default",
"host": "db-valkey-auth",
"generated_id": "16678561-ff7e-4c97-8c83-0adeff214681"
}
]
}
+1 -2
View File
@@ -14,7 +14,7 @@ services:
LOG: info
TZ: "Europe/Paris"
# DATABASES_CONFIG_FILE: "config.toml"
EDGE_KEY: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiOGZmMDE4NTQtYjJhMS00ZTE0LTkwMjctZTJiOWIxZjQ1YzdlIiwicHVibGljS2V5IjoiLS0tLS1CRUdJTiBSU0EgUFVCTElDIEtFWS0tLS0tXG5NSUlCQ2dLQ0FRRUE5TWV4M2pmdnVLdFB5YU1ERnh2Ulp2dmd3YkRJQ2JzQi81Wll5NDNSVVRBaXZRYjJiSDdYXG5qRHBQd1lJeCs4UFBrbHlRbDVMQzV1UWZEaCs4SVd4OG1LZ3FvMXpWMkdiZXdGbEdEWFYxVEdyU1ZEU25aSWR4XG52bWdYc29EeXhVMlJvWUFUMS9YMWxuc2YxenZKdkFMTkhXdEhRdk42SjVDZTFSMmFsendVRGFEVXlJNzRmSldQXG5tNTh0SDMrYklXL0VVTXdjaWNxM0oySWw3Vm9KNkZNUHJQL1ZSOWEvdFF1SU1qa200MXpFY2NscExPa2luRkxuXG54NmVUWkFSZUpya2UrbnRvZ2t4TGEyRWV5a1lUNzB4V3hKNWp5ZExBVnRvNkkyQlVLVVJoTkowTUFaU29NYUtvXG5iMGJRcnY1UzExZWllMnMrT2I3aTYzSFpkVUx0UmV1MVJ3SURBUUFCXG4tLS0tLUVORCBSU0EgUFVCTElDIEtFWS0tLS0tXG4ifQ"
EDGE_KEY: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiNWU1OGU2MGEtODhiMy00YTBjLWI0NDktNTQ3OWZhOTQzZDBkIiwibWFzdGVyS2V5QjY0IjoiQlhWM1hvbEM2NTZTVjdkTmdjV1BHUWxrKytycExJNmxHRGk3Q1BCNWllbz0ifQ=="
extra_hosts:
- "localhost:host-gateway"
networks:
@@ -63,7 +63,6 @@ volumes:
mongodb-data:
mongodb-data-auth:
networks:
portabase:
name: portabase_network
+122 -73
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
# - sqlite-data:/sqlite-data/workspace/data
# - ./scripts/sqlite/test-db:/sqlite-data-2/workspace/data
# - 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: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiNWU1OGU2MGEtODhiMy00YTBjLWI0NDktNTQ3OWZhOTQzZDBkIiwibWFzdGVyS2V5QjY0IjoiQlhWM1hvbEM2NTZTVjdkTmdjV1BHUWxrKytycExJNmxHRGk3Q1BCNWllbz0ifQ=="
#POOLING: 1
#DATABASES_CONFIG_FILE: "config.toml"
extra_hosts:
@@ -39,85 +39,134 @@ services:
- POSTGRES_PASSWORD=changeme
networks:
- portabase
#
# db-mariadb:
# container_name: db-mariadb
# image: mariadb:latest
# ports:
# - "3311:3306"
# environment:
# - MYSQL_DATABASE=mariadb
# - MYSQL_USER=mariadb
# - MYSQL_PASSWORD=changeme
# - MYSQL_RANDOM_ROOT_PASSWORD=yes
# volumes:
# - mariadb-data:/var/lib/mysql
# networks:
# - portabase
#
#
# db-mongodb-auth:
# container_name: db-mongodb-auth
# image: mongo:latest
# ports:
# - "27082:27017"
# environment:
# MONGO_INITDB_ROOT_USERNAME: root
# MONGO_INITDB_ROOT_PASSWORD: rootpassword
# MONGO_INITDB_DATABASE: testdbauth
# command: mongod --auth
# networks:
# - portabase
# volumes:
# - mongodb-data-auth:/data/db
# healthcheck:
# test: [ "CMD", "mongo", "--eval", "db.adminCommand('ping')" ]
# interval: 5s
# timeout: 5s
# retries: 10
#
# db-mongodb:
# container_name: db-mongodb
# image: mongo:latest
# ports:
# - "27083:27017"
# volumes:
# - mongodb-data:/data/db
# healthcheck:
# test: [ "CMD", "mongosh", "--eval", "db.adminCommand('ping')" ]
# interval: 5s
# timeout: 5s
# retries: 10
# environment:
# MONGO_INITDB_DATABASE: testdb
# networks:
# - portabase
#
# db-mariadb:
# container_name: db-mariadb
# image: mariadb:latest
# ports:
# - "3311:3306"
# environment:
# - MYSQL_DATABASE=mariadb
# - MYSQL_USER=mariadb
# - MYSQL_PASSWORD=changeme
# - MYSQL_RANDOM_ROOT_PASSWORD=yes
# volumes:
# - mariadb-data:/var/lib/mysql
# networks:
# - portabase
#
#
# db-mongodb-auth:
# container_name: db-mongodb-auth
# image: mongo:latest
# ports:
# - "27082:27017"
# environment:
# MONGO_INITDB_ROOT_USERNAME: root
# MONGO_INITDB_ROOT_PASSWORD: rootpassword
# MONGO_INITDB_DATABASE: testdbauth
# command: mongod --auth
# networks:
# - portabase
# volumes:
# - mongodb-data-auth:/data/db
# healthcheck:
# test: [ "CMD", "mongo", "--eval", "db.adminCommand('ping')" ]
# interval: 5s
# timeout: 5s
# retries: 10
#
# db-mongodb:
# container_name: db-mongodb
# image: mongo:latest
# ports:
# - "27083:27017"
# volumes:
# - mongodb-data:/data/db
# healthcheck:
# test: [ "CMD", "mongosh", "--eval", "db.adminCommand('ping')" ]
# interval: 5s
# timeout: 5s
# retries: 10
# environment:
# MONGO_INITDB_DATABASE: testdb
# networks:
# - portabase
# sqlite:
# container_name: db-sqlite
# image: keinos/sqlite3
# volumes:
# - sqlite-data:/workspace/data
# working_dir: /workspace
# command: tail -f /dev/null
# stdin_open: true
# tty: true
# sqlite:
# container_name: db-sqlite
# image: keinos/sqlite3
# volumes:
# - sqlite-data:/workspace/data
# working_dir: /workspace
# command: tail -f /dev/null
# stdin_open: true
# tty: true
db-redis:
image: redis:latest
container_name: db-redis
ports:
- "6379:6379"
volumes:
- redis-data:/data
command: [ "redis-server", "--appendonly", "yes" ]
networks:
- portabase
db-redis-auth:
image: redis:latest
container_name: db-redis-auth
ports:
- "6380:6379"
volumes:
- redis-data-auth:/data
environment:
- REDIS_PASSWORD=supersecurepassword
command: [ "redis-server", "--requirepass", "supersecurepassword", "--appendonly", "yes" ]
networks:
- portabase
db-valkey:
image: valkey/valkey
container_name: db-valkey
environment:
- ALLOW_EMPTY_PASSWORD=yes
ports:
- '6381:6379'
volumes:
- valkey-data:/data
networks:
- portabase
db-valkey-auth:
image: valkey/valkey
container_name: db-valkey-auth
command: >
--requirepass "supersecurepassword"
ports:
- '6382:6379'
volumes:
- valkey-data-auth:/data
networks:
- portabase
volumes:
cargo-registry:
cargo-git:
# cargo-target:
# cargo-target:
postgres-data:
# mariadb-data:
# mongodb-data:
# mongodb-data-auth:
# sqlite-data:
# mariadb-data:
# mongodb-data:
# mongodb-data-auth:
# sqlite-data:
redis-data:
redis-data-auth:
valkey-data:
valkey-data-auth:
networks:
portabase:
name: portabase_network
external: true
# docker network create portabase_network
+5 -1
View File
@@ -1,7 +1,7 @@
# =========================
# Base image (shared)
# =========================
FROM rust:1.92.0 AS base
FROM rust:1.94.0 AS base
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \
pkg-config \
@@ -16,6 +16,8 @@ RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \
curl \
mariadb-client \
sqlite3 \
redis-tools \
valkey \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
@@ -94,6 +96,8 @@ RUN apt-get update && apt-get install -y \
zlib1g \
mariadb-client \
sqlite3 \
redis-tools \
valkey \
&& rm -rf /var/lib/apt/lists/*
+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."
+10 -3
View File
@@ -34,16 +34,23 @@ if [ -n "$TZ" ]; then
fi
fi
REDIS_PORT=65515
echo "[entrypoint] APP_ENV=$APP_ENV"
echo "[entrypoint] Starting Redis..."
redis-server --daemonize yes
redis-server --port $REDIS_PORT --daemonize yes
echo "[entrypoint] Waiting for Redis to be ready..."
until redis-cli ping >/dev/null 2>&1; do
MAX_RETRIES=20
COUNT=0
until redis-cli -h localhost -p "$REDIS_PORT" ping >/dev/null 2>&1 ; do
COUNT=$((COUNT+1))
if [ $COUNT -ge $MAX_RETRIES ]; then
echo "[ERROR] Redis did not start after $MAX_RETRIES attempts"
exit 1
fi
echo "[entrypoint] Redis not ready, sleeping 1s..."
sleep 1
done
echo "[entrypoint] Redis is ready"
+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"
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
set -e
POSTGRES_BASE="/usr/local/postgresql"
echo "Detecting OS and architecture..."
OS_TYPE="$(uname -s)"
ARCH="$(uname -m)"
install_pg_binaries() {
echo "Installing PostgreSQL binaries for versions 12-18..."
for v in 12 13 14 15 16 17 18; do
TARGET_DIR="$POSTGRES_BASE/$v/bin"
sudo mkdir -p "$TARGET_DIR"
if [[ "$OS_TYPE" == "Linux" ]]; then
if [[ "$ARCH" == "x86_64" ]]; then
SRC_DIR="./assets/tools/amd64/postgresql/postgresql-$v/bin"
elif [[ "$ARCH" == "aarch64" ]]; then
SRC_DIR="./assets/tools/arm64/postgresql/postgresql-$v/bin"
else
echo "Unsupported architecture: $ARCH"
continue
fi
if [[ -d "$SRC_DIR" ]]; then
echo "Copying PostgreSQL $v binaries from $SRC_DIR to $TARGET_DIR"
sudo cp -r "$SRC_DIR"/* "$TARGET_DIR/"
else
echo "Binaries for PostgreSQL $v not found for Linux, skipping..."
continue
fi
elif [[ "$OS_TYPE" == "Darwin" ]]; then
PG_SRC="$(brew --prefix postgresql@$v)/bin" 2>/dev/null || true
if [[ ! -d "$PG_SRC" ]]; then
echo "PostgreSQL $v not installed via Homebrew. Trying to install..."
if ! brew install postgresql@$v; then
echo "PostgreSQL $v not available, skipping..."
continue
fi
PG_SRC="$(brew --prefix postgresql@$v)/bin"
fi
echo "Copying PostgreSQL $v binaries from $PG_SRC to $TARGET_DIR"
sudo cp -r "$PG_SRC"/* "$TARGET_DIR/"
fi
sudo chown -R "$(whoami)" "$TARGET_DIR"
chmod +x "$TARGET_DIR"/*
done
echo "PostgreSQL binaries installed under $POSTGRES_BASE"
}
if [[ "$OS_TYPE" == "Linux" ]]; then
if command -v apt >/dev/null 2>&1; then
echo "Linux detected with apt. Installing prerequisites..."
sudo apt update
sudo apt install -y wget gnupg lsb-release redis-tools valkey
install_pg_binaries
else
echo "Unsupported Linux distribution. Only apt-based distros are supported."
exit 1
fi
elif [[ "$OS_TYPE" == "Darwin" ]]; then
if command -v brew >/dev/null 2>&1; then
echo "macOS detected. Installing prerequisites..."
brew install redis
brew install valkey
sudo mkdir -p "$POSTGRES_BASE"
sudo chown -R "$(whoami)" "$POSTGRES_BASE"
install_pg_binaries
else
echo "Homebrew not found. Please install Homebrew first: https://brew.sh/"
exit 1
fi
else
echo "Unsupported OS: $OS_TYPE"
exit 1
fi
echo "Tools installation completed successfully."
+3 -2
View File
@@ -43,9 +43,10 @@ impl Agent {
let ping_result = self.status_service.ping(&config.databases).await?;
for db in ping_result.databases.iter() {
let database = config.databases.iter().find(|cfg_db|cfg_db.generated_id == db.generated_id).unwrap();
info!(
"Generated Id: {} | backup action: {} | restore action: {}",
db.generated_id, db.data.backup.action, db.data.restore.action
"Generated Id: {} | backup action: {} | restore action: {} | Database Name: {}",
db.generated_id, db.data.backup.action, db.data.restore.action, database.name,
);
let _ = self.cron_service.sync(db).await;
+10 -3
View File
@@ -2,18 +2,20 @@ use crate::domain::mongodb::database::MongoDatabase;
use crate::domain::mysql::database::MySQLDatabase;
use crate::domain::postgres::database::PostgresDatabase;
use crate::domain::postgres::{detect_format_from_file, detect_format_from_size};
use crate::domain::redis::database::RedisDatabase;
use crate::domain::sqlite::database::SqliteDatabase;
use crate::services::config::{DatabaseConfig, DbType};
use anyhow::Result;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::domain::sqlite::database::SqliteDatabase;
use crate::domain::valkey::database::ValkeyDatabase;
#[async_trait::async_trait]
pub trait Database: Send + Sync {
fn file_extension(&self) -> &'static str;
async fn ping(&self) -> Result<bool>;
async fn backup(&self, backup_dir: &Path) -> Result<PathBuf>;
async fn restore(&self, restore_file: &Path) -> Result<()>;
async fn backup(&self, backup_dir: &Path, is_test: Option<bool>) -> Result<PathBuf>;
async fn restore(&self, restore_file: &Path, is_test: Option<bool>) -> Result<()>;
}
pub struct DatabaseFactory;
@@ -29,6 +31,8 @@ impl DatabaseFactory {
DbType::Mariadb => Arc::new(MySQLDatabase::new(cfg)),
DbType::MongoDB => Arc::new(MongoDatabase::new(cfg)),
DbType::Sqlite => Arc::new(SqliteDatabase::new(cfg)),
DbType::Redis => Arc::new(RedisDatabase::new(cfg)),
DbType::Valkey => Arc::new(ValkeyDatabase::new(cfg))
}
}
@@ -42,6 +46,9 @@ impl DatabaseFactory {
DbType::Mariadb => Arc::new(MySQLDatabase::new(cfg)),
DbType::MongoDB => Arc::new(MongoDatabase::new(cfg)),
DbType::Sqlite => Arc::new(SqliteDatabase::new(cfg)),
DbType::Redis => Arc::new(RedisDatabase::new(cfg)),
DbType::Valkey => Arc::new(ValkeyDatabase::new(cfg))
}
}
}
+2
View File
@@ -3,4 +3,6 @@ pub mod postgres;
pub mod mysql;
mod mongodb;
mod sqlite;
mod redis;
mod valkey;
+17 -12
View File
@@ -27,22 +27,27 @@ impl Database for MongoDatabase {
ping::run(self.cfg.clone()).await
}
async fn backup(&self, dir: &Path) -> Result<PathBuf> {
FileLock::acquire(&self.cfg.generated_id, DbOpLock::Backup.as_str()).await?;
let res = backup::run(
self.cfg.clone(),
dir.to_path_buf(),
self.file_extension(),
)
.await;
FileLock::release(&self.cfg.generated_id).await?;
async fn backup(&self, dir: &Path, is_test: Option<bool>) -> Result<PathBuf> {
let test_mode = is_test.unwrap_or(false);
if !test_mode {
FileLock::acquire(&self.cfg.generated_id, DbOpLock::Backup.as_str()).await?;
}
let res = backup::run(self.cfg.clone(), dir.to_path_buf(), self.file_extension()).await;
if !test_mode {
FileLock::release(&self.cfg.generated_id).await?;
}
res
}
async fn restore(&self, file: &Path) -> Result<()> {
FileLock::acquire(&self.cfg.generated_id, DbOpLock::Restore.as_str()).await?;
async fn restore(&self, file: &Path, is_test: Option<bool>) -> Result<()> {
let test_mode = is_test.unwrap_or(false);
if !test_mode {
FileLock::acquire(&self.cfg.generated_id, DbOpLock::Restore.as_str()).await?;
}
let res = restore::run(self.cfg.clone(), file.to_path_buf()).await;
FileLock::release(&self.cfg.generated_id).await?;
if !test_mode {
FileLock::release(&self.cfg.generated_id).await?;
}
res
}
}
+17 -6
View File
@@ -36,17 +36,28 @@ impl Database for MySQLDatabase {
ping::run(self.cfg.clone(), self.build_env().clone()).await
}
async fn backup(&self, dir: &Path) -> Result<PathBuf> {
FileLock::acquire(&self.cfg.generated_id, DbOpLock::Backup.as_str()).await?;
async fn backup(&self, dir: &Path, is_test: Option<bool>) -> Result<PathBuf> {
let test_mode = is_test.unwrap_or(false);
if !test_mode {
FileLock::acquire(&self.cfg.generated_id, DbOpLock::Backup.as_str()).await?;
}
let res = backup::run(self.cfg.clone(), dir.to_path_buf(), self.build_env().clone(), self.file_extension()).await;
FileLock::release(&self.cfg.generated_id).await?;
if !test_mode {
FileLock::release(&self.cfg.generated_id).await?;
}
res
}
async fn restore(&self, file: &Path) -> Result<()> {
FileLock::acquire(&self.cfg.generated_id, DbOpLock::Restore.as_str()).await?;
async fn restore(&self, file: &Path, is_test: Option<bool>) -> Result<()> {
let test_mode = is_test.unwrap_or(false);
if !test_mode {
FileLock::acquire(&self.cfg.generated_id, DbOpLock::Restore.as_str()).await?;
}
let res = restore::run(self.cfg.clone(), file.to_path_buf()).await;
FileLock::release(&self.cfg.generated_id).await?;
if !test_mode {
FileLock::release(&self.cfg.generated_id).await?;
}
res
}
}
+3 -1
View File
@@ -11,6 +11,7 @@ pub async fn run(
cfg: DatabaseConfig,
format: PostgresDumpFormat,
backup_dir: PathBuf,
is_test: Option<bool>
) -> Result<PathBuf> {
tokio::task::spawn_blocking(move || -> Result<PathBuf> {
debug!("Starting backup for database {}", cfg.name);
@@ -26,7 +27,8 @@ pub async fn run(
}
};
let pg_dump = select_pg_path(&version).join("pg_dump");
let pg_dump = select_pg_path(&version, is_test).join("pg_dump");
debug!("Using pg_dump at {:?}", pg_dump);
match format {
+7 -2
View File
@@ -28,9 +28,14 @@ pub async fn server_version(cfg: &DatabaseConfig) -> Result<String> {
Ok(version)
}
pub fn select_pg_path(version: &str) -> std::path::PathBuf {
pub fn select_pg_path(version: &str, is_test: Option<bool>) -> std::path::PathBuf {
let major = version.split('.').next().unwrap_or("17");
format!("/usr/lib/postgresql/{}/bin", major).into()
if is_test.unwrap_or(false) {
format!("/usr/local/postgresql/{}/bin", major).into()
} else {
format!("/usr/lib/postgresql/{}/bin", major).into()
}
}
pub async fn terminate_connections(cfg: &DatabaseConfig) -> Result<()> {
+19 -13
View File
@@ -2,11 +2,7 @@ use anyhow::Result;
use async_trait::async_trait;
use std::path::{Path, PathBuf};
use super::{
backup,
format::PostgresDumpFormat,
ping, restore,
};
use super::{backup, format::PostgresDumpFormat, ping, restore};
use crate::domain::factory::Database;
use crate::services::config::DatabaseConfig;
use crate::utils::locks::{DbOpLock, FileLock};
@@ -35,17 +31,27 @@ impl Database for PostgresDatabase {
ping::run(self.cfg.clone()).await
}
async fn backup(&self, dir: &Path) -> Result<PathBuf> {
FileLock::acquire(&self.cfg.generated_id, DbOpLock::Backup.as_str()).await?;
let res = backup::run(self.cfg.clone(), self.format, dir.to_path_buf()).await;
FileLock::release(&self.cfg.generated_id).await?;
async fn backup(&self, dir: &Path, is_test: Option<bool>) -> Result<PathBuf> {
let test_mode = is_test.unwrap_or(false);
if !test_mode {
FileLock::acquire(&self.cfg.generated_id, DbOpLock::Backup.as_str()).await?;
}
let res = backup::run(self.cfg.clone(), self.format, dir.to_path_buf(), is_test).await;
if !test_mode {
FileLock::release(&self.cfg.generated_id).await?;
}
res
}
async fn restore(&self, file: &Path) -> Result<()> {
FileLock::acquire(&self.cfg.generated_id, DbOpLock::Restore.as_str()).await?;
let res = restore::run(self.cfg.clone(), self.format, file.to_path_buf()).await;
FileLock::release(&self.cfg.generated_id).await?;
async fn restore(&self, file: &Path, is_test: Option<bool>) -> Result<()> {
let test_mode = is_test.unwrap_or(false);
if !test_mode {
FileLock::acquire(&self.cfg.generated_id, DbOpLock::Restore.as_str()).await?;
}
let res = restore::run(self.cfg.clone(), self.format, file.to_path_buf(), is_test).await;
if !test_mode {
FileLock::release(&self.cfg.generated_id).await?;
}
res
}
}
+3 -1
View File
@@ -11,6 +11,7 @@ pub async fn run(
cfg: DatabaseConfig,
format: PostgresDumpFormat,
restore_file: PathBuf,
is_test: Option<bool>,
) -> Result<()> {
tokio::task::spawn_blocking(move || -> Result<()> {
debug!("Starting restore for database {}", cfg.name);
@@ -26,7 +27,8 @@ pub async fn run(
}
};
let pg_restore = select_pg_path(&version).join("pg_restore");
let pg_restore = select_pg_path(&version, is_test).join("pg_restore");
debug!("Using pg_restore at {:?}", pg_restore);
if let Err(e) = futures::executor::block_on(terminate_connections(&cfg)) {
+65
View File
@@ -0,0 +1,65 @@
use crate::services::config::DatabaseConfig;
use anyhow::{Context, Result};
use std::path::PathBuf;
use std::process::Command;
use tracing::{debug, error, info};
pub async fn run(
cfg: DatabaseConfig,
backup_dir: PathBuf,
file_extension: &'static str,
) -> Result<PathBuf> {
tokio::task::spawn_blocking(move || -> Result<PathBuf> {
debug!("Starting Redis backup for database {}", cfg.name);
let file_path = backup_dir.join(format!("{}{}", cfg.generated_id, file_extension));
let mut cmd = Command::new("redis-cli");
cmd.arg("-h")
.arg(&cfg.host)
.arg("-p")
.arg(cfg.port.to_string());
if !cfg.username.is_empty() {
cmd.arg("--user").arg(&cfg.username);
}
if !cfg.password.is_empty() {
cmd.arg("-a").arg(&cfg.password);
}
cmd.arg("--rdb").arg(&file_path);
debug!("Command Backup: {:?}", cmd);
let output = cmd.output().context("Redis backup command failed")?;
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
if !output.status.success() {
if stderr.contains("NOAUTH") {
error!(
"Redis backup failed for {}: Authentication required (NOAUTH)",
cfg.name
);
anyhow::bail!(
"Redis backup failed for {}: Authentication required",
cfg.name
);
} else {
error!("Redis backup failed for {}: {}", cfg.name, stderr);
anyhow::bail!("Redis backup failed for {}: {}", cfg.name, stderr);
}
}
info!(
"Redis backup completed for {}. Output: {}",
cfg.name, stdout
);
Ok(file_path)
})
.await?
}
+45
View File
@@ -0,0 +1,45 @@
use anyhow::{Result, bail};
use async_trait::async_trait;
use std::path::{Path, PathBuf};
use crate::domain::factory::Database;
use crate::domain::redis::{backup, ping};
use crate::services::config::DatabaseConfig;
use crate::utils::locks::{DbOpLock, FileLock};
pub struct RedisDatabase {
cfg: DatabaseConfig,
}
impl RedisDatabase {
pub fn new(cfg: DatabaseConfig) -> Self {
Self { cfg }
}
}
#[async_trait]
impl Database for RedisDatabase {
fn file_extension(&self) -> &'static str {
".rdb"
}
async fn ping(&self) -> Result<bool> {
ping::run(self.cfg.clone()).await
}
async fn backup(&self, dir: &Path, is_test: Option<bool>) -> Result<PathBuf> {
let test_mode = is_test.unwrap_or(false);
if !test_mode {
FileLock::acquire(&self.cfg.generated_id, DbOpLock::Backup.as_str()).await?;
}
let res = backup::run(self.cfg.clone(), dir.to_path_buf(), self.file_extension()).await;
if !test_mode {
FileLock::release(&self.cfg.generated_id).await?;
}
res
}
async fn restore(&self, _file: &Path, _is_test: Option<bool>) -> Result<()> {
bail!("Restore not supported for Redis databases")
}
}
+3
View File
@@ -0,0 +1,3 @@
mod backup;
pub mod database;
mod ping;
+55
View File
@@ -0,0 +1,55 @@
use tracing::{debug, info};
use crate::services::config::DatabaseConfig;
use tokio::process::Command;
use tokio::time::{timeout, Duration};
use anyhow::{Result, Context};
pub async fn run(cfg: DatabaseConfig) -> Result<bool> {
let mut cmd = Command::new("redis-cli");
cmd.arg("-h")
.arg(&cfg.host)
.arg("-p")
.arg(cfg.port.to_string());
if !cfg.username.is_empty() {
cmd.arg("--user").arg(&cfg.username);
}
if !cfg.password.is_empty() {
cmd.arg("-a").arg(&cfg.password);
}
cmd.arg("PING");
debug!("Command Ping: {:?}", cmd);
let result = timeout(Duration::from_secs(10), cmd.output()).await;
match result {
Ok(output) => {
let output = output.context("Failed to execute redis-cli")?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
info!("Redis stdout: {}", stdout);
info!("Redis stderr: {}", stderr);
if stderr.contains("NOAUTH") {
info!("Redis authentication failed (NOAUTH required)");
return Ok(false);
}
if !output.status.success() {
info!("Redis command failed with status: {:?}", output.status);
return Ok(false);
}
Ok(stdout.contains("PONG"))
}
Err(_) => {
info!("Timeout connecting to Redis at {}:{}", cfg.host, cfg.port);
Ok(false)
}
}
}
+16 -7
View File
@@ -27,17 +27,26 @@ impl Database for SqliteDatabase {
ping::run(self.cfg.clone()).await
}
async fn backup(&self, dir: &Path) -> Result<PathBuf> {
FileLock::acquire(&self.cfg.generated_id, DbOpLock::Backup.as_str()).await?;
async fn backup(&self, dir: &Path, is_test: Option<bool>) -> Result<PathBuf> {
let test_mode = is_test.unwrap_or(false);
if !test_mode {
FileLock::acquire(&self.cfg.generated_id, DbOpLock::Backup.as_str()).await?;
}
let res = backup::run(self.cfg.clone(), dir.to_path_buf(), self.file_extension()).await;
FileLock::release(&self.cfg.generated_id).await?;
if !test_mode {
FileLock::release(&self.cfg.generated_id).await?;
}
res
}
async fn restore(&self, file: &Path) -> Result<()> {
FileLock::acquire(&self.cfg.generated_id, DbOpLock::Restore.as_str()).await?;
async fn restore(&self, file: &Path, is_test: Option<bool>) -> Result<()> {
let test_mode = is_test.unwrap_or(false);
if !test_mode {
FileLock::acquire(&self.cfg.generated_id, DbOpLock::Restore.as_str()).await?;
}
let res = restore::run(self.cfg.clone(), file.to_path_buf()).await;
FileLock::release(&self.cfg.generated_id).await?;
if !test_mode {
FileLock::release(&self.cfg.generated_id).await?;
}
res
}
}
+65
View File
@@ -0,0 +1,65 @@
use crate::services::config::DatabaseConfig;
use anyhow::{Context, Result};
use std::path::PathBuf;
use std::process::Command;
use tracing::{debug, error, info};
pub async fn run(
cfg: DatabaseConfig,
backup_dir: PathBuf,
file_extension: &'static str,
) -> Result<PathBuf> {
tokio::task::spawn_blocking(move || -> Result<PathBuf> {
debug!("Starting Valkey backup for database {}", cfg.name);
let file_path = backup_dir.join(format!("{}{}", cfg.generated_id, file_extension));
let mut cmd = Command::new("valkey-cli");
cmd.arg("-h")
.arg(&cfg.host)
.arg("-p")
.arg(cfg.port.to_string());
if !cfg.username.is_empty() {
cmd.arg("--user").arg(&cfg.username);
}
if !cfg.password.is_empty() {
cmd.arg("-a").arg(&cfg.password);
}
cmd.arg("--rdb").arg(&file_path);
debug!("Command Backup: {:?}", cmd);
let output = cmd.output().context("Valkey backup command failed")?;
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
if !output.status.success() {
if stderr.contains("NOAUTH") {
error!(
"Valkey backup failed for {}: Authentication required (NOAUTH)",
cfg.name
);
anyhow::bail!(
"Valkey backup failed for {}: Authentication required",
cfg.name
);
} else {
error!("Valkey backup failed for {}: {}", cfg.name, stderr);
anyhow::bail!("Valkey backup failed for {}: {}", cfg.name, stderr);
}
}
info!(
"Valkey backup completed for {}. Output: {}",
cfg.name, stdout
);
Ok(file_path)
})
.await?
}
+45
View File
@@ -0,0 +1,45 @@
use anyhow::{Result, bail};
use async_trait::async_trait;
use std::path::{Path, PathBuf};
use crate::domain::factory::Database;
use crate::domain::valkey::{backup, ping};
use crate::services::config::DatabaseConfig;
use crate::utils::locks::{DbOpLock, FileLock};
pub struct ValkeyDatabase {
cfg: DatabaseConfig,
}
impl ValkeyDatabase {
pub fn new(cfg: DatabaseConfig) -> Self {
Self { cfg }
}
}
#[async_trait]
impl Database for ValkeyDatabase {
fn file_extension(&self) -> &'static str {
".rdb"
}
async fn ping(&self) -> Result<bool> {
ping::run(self.cfg.clone()).await
}
async fn backup(&self, dir: &Path, is_test: Option<bool>) -> Result<PathBuf> {
let test_mode = is_test.unwrap_or(false);
if !test_mode {
FileLock::acquire(&self.cfg.generated_id, DbOpLock::Backup.as_str()).await?;
}
let res = backup::run(self.cfg.clone(), dir.to_path_buf(), self.file_extension()).await;
if !test_mode {
FileLock::release(&self.cfg.generated_id).await?;
}
res
}
async fn restore(&self, _file: &Path, _is_test: Option<bool>) -> Result<()> {
bail!("Restore not supported for Valkey databases")
}
}
+3
View File
@@ -0,0 +1,3 @@
mod backup;
pub mod database;
mod ping;
+55
View File
@@ -0,0 +1,55 @@
use tracing::{debug, info};
use crate::services::config::DatabaseConfig;
use tokio::process::Command;
use tokio::time::{timeout, Duration};
use anyhow::{Result, Context};
pub async fn run(cfg: DatabaseConfig) -> Result<bool> {
let mut cmd = Command::new("valkey-cli");
cmd.arg("-h")
.arg(&cfg.host)
.arg("-p")
.arg(cfg.port.to_string());
if !cfg.username.is_empty() {
cmd.arg("--user").arg(&cfg.username);
}
if !cfg.password.is_empty() {
cmd.arg("-a").arg(&cfg.password);
}
cmd.arg("PING");
debug!("Command Ping: {:?}", cmd);
let result = timeout(Duration::from_secs(10), cmd.output()).await;
match result {
Ok(output) => {
let output = output.context("Failed to execute redis-cli")?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
info!("Redis stdout: {}", stdout);
info!("Redis stderr: {}", stderr);
if stderr.contains("NOAUTH") {
info!("Redis authentication failed (NOAUTH required)");
return Ok(false);
}
if !output.status.success() {
info!("Redis command failed with status: {:?}", output.status);
return Ok(false);
}
Ok(stdout.contains("PONG"))
}
Err(_) => {
info!("Timeout connecting to Redis at {}:{}", cfg.host, cfg.port);
Ok(false)
}
}
}
+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 -1
View File
@@ -40,7 +40,7 @@ impl BackupService {
});
}
match db.backup(tmp_path).await {
match db.backup(tmp_path, Some(false)).await {
Ok(file) => Ok(BackupResult {
generated_id,
+7 -4
View File
@@ -19,7 +19,8 @@ pub enum DbType {
Postgresql,
MongoDB,
Sqlite,
// Add other DB types if needed
Redis,
Valkey
}
impl DbType {
@@ -30,6 +31,8 @@ impl DbType {
DbType::Postgresql => "postgresql",
DbType::MongoDB => "mongodb",
DbType::Sqlite => "sqlite",
DbType::Redis => "redis",
DbType::Valkey => "valkey",
}
}
}
@@ -162,17 +165,17 @@ impl ConfigService {
};
let host = match db.db_type {
DbType::Postgresql | DbType::Mysql | DbType::Mariadb | DbType::MongoDB => required(&db.host, &db.name, "host")?,
DbType::Postgresql | DbType::Mysql | DbType::Mariadb | DbType::MongoDB | DbType::Redis | DbType::Valkey => required(&db.host, &db.name, "host")?,
DbType::Sqlite => optional(&db.host),
};
let port = match db.db_type {
DbType::Postgresql | DbType::Mysql | DbType::Mariadb | DbType::MongoDB => required(&db.port, &db.name, "port")?,
DbType::Postgresql | DbType::Mysql | DbType::Mariadb | DbType::MongoDB | DbType::Redis | DbType::Valkey => required(&db.port, &db.name, "port")?,
DbType::Sqlite => db.port.unwrap_or(0),
};
let database_name = match db.db_type {
DbType::Sqlite => optional(&db.database),
DbType::Sqlite | DbType::Redis | DbType::Valkey => optional(&db.database),
_ => required(&db.database, &db.name, "database")?
};
+1 -1
View File
@@ -31,7 +31,7 @@ impl RestoreService {
});
}
match db.restore(&backup_file).await {
match db.restore(&backup_file, Some(false)).await {
Ok(_) => Ok(RestoreResult {
generated_id,
+10
View File
@@ -16,6 +16,9 @@ use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
use futures::StreamExt;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use aws_config::retry::RetryConfig;
use aws_sdk_s3::config::retry::ReconnectMode;
use tokio::fs;
use tracing::{error, info};
use crate::services::backup::models::{BackupResult, UploadResult};
@@ -107,7 +110,14 @@ impl StorageProvider for S3Provider {
info!("S3 endpoint to {}", &endpoint);
let retry_config = RetryConfig::standard()
.with_max_attempts(5)
.with_initial_backoff(Duration::from_millis(200))
.with_max_backoff(Duration::from_secs(5))
.with_reconnect_mode(ReconnectMode::ReuseAllConnections);
let sdk_config = s3::config::Builder::new()
.retry_config(retry_config)
.credentials_provider(credentials)
.region(region)
.force_path_style(true)
@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use crate::utils::deserializer::string_or_number_to_string;
#[derive(Debug, Deserialize, Serialize)]
pub struct S3ProviderConfig {
@@ -8,5 +9,7 @@ pub struct S3ProviderConfig {
pub end_point_url: String,
pub ssl: bool,
pub region: Option<String>,
#[serde(default, deserialize_with = "string_or_number_to_string")]
pub port: Option<String>,
}
+1 -1
View File
@@ -31,7 +31,7 @@ impl Settings {
app_version: env!("CARGO_PKG_VERSION").to_string(),
app_env: env::var("APP_ENV").unwrap_or_else(|_| "development".into()),
redis_url: env::var("CELERY_BROKER_URL")
.unwrap_or_else(|_| "redis://localhost:6379/".into()),
.unwrap_or_else(|_| "redis://localhost:65515/".into()),
edge_key: env::var("EDGE_KEY").unwrap_or_default(),
databases_config_file: env::var("DATABASES_CONFIG_FILE")
.unwrap_or_else(|_| "config.json".into()),
+3
View File
@@ -0,0 +1,3 @@
mod postgres;
mod redis;
mod valkey;
+109
View File
@@ -0,0 +1,109 @@
use crate::domain::factory::DatabaseFactory;
use crate::services::config::{DatabaseConfig, DbType};
use crate::tests::init_tracing_for_test;
use crate::utils::compress::{compress_to_tar_gz_large, decompress_large_tar_gz};
use oauth2::url;
use std::path::PathBuf;
use tempfile::TempDir;
use testcontainers::runners::AsyncRunner;
use testcontainers::{ContainerAsync, ImageExt};
use testcontainers_modules::postgres::Postgres;
use tracing::{error, info};
use url::Host;
async fn create_config() -> (ContainerAsync<Postgres>, DatabaseConfig) {
let container = Postgres::default()
.with_env_var("POSTGRES_DB", "testdb")
.with_env_var("POSTGRES_USER", "testuser")
.with_env_var("POSTGRES_PASSWORD", "changeme")
.with_tag("17")
.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: "testdb".to_string(),
db_type: DbType::Postgresql,
username: "testuser".to_string(),
password: "changeme".to_string(),
port,
host: host.to_string(),
generated_id: "40875631-e3d2-4dfe-a26b-2a347ecc64fd".to_string(),
path: "".to_string(),
};
(container, config)
}
#[tokio::test]
async fn postgres_ping_test() {
init_tracing_for_test();
let (_container, config) = create_config().await;
let db = DatabaseFactory::create_for_backup(config.clone()).await;
let reachable = db.ping().await.unwrap_or_else(|_| false);
assert_eq!(reachable, true);
}
#[tokio::test]
async fn postgres_backup_restore_test() {
init_tracing_for_test();
let (_container, config) = create_config().await;
let temp_dir = TempDir::new().unwrap();
let backup_path = temp_dir.path();
let db = DatabaseFactory::create_for_backup(config.clone()).await;
let file_path = db.backup(backup_path, Some(true)).await.unwrap();
assert!(file_path.is_file());
let compression = compress_to_tar_gz_large(&file_path).await.unwrap();
assert!(compression.compressed_path.is_file());
let files = decompress_large_tar_gz(compression.compressed_path.as_path(), temp_dir.path())
.await
.unwrap();
let backup_file: PathBuf;
if files.len() == 1 {
backup_file = files[0].clone()
} else {
backup_file = "".into()
}
let db = DatabaseFactory::create_for_restore(config.clone(), &backup_file).await;
let reachable = db.ping().await.unwrap_or(false);
info!("Reachable: {}", reachable);
assert_eq!(reachable, true);
info!("Running pg_restore: {:?}", backup_file);
match db.restore(&backup_file, Some(true)).await {
Ok(_) => {
info!("Restore succeeded for {}", config.generated_id);
assert!(true)
}
Err(e) => {
error!("Restore failed for {}: {:?}", config.generated_id, e);
assert!(false)
}
}
}
+65
View File
@@ -0,0 +1,65 @@
use tempfile::TempDir;
use testcontainers::runners::AsyncRunner;
use testcontainers::ContainerAsync;
use testcontainers_modules::redis::Redis;
use url::Host;
use crate::domain::factory::DatabaseFactory;
use crate::services::config::{DatabaseConfig, DbType};
use crate::tests::init_tracing_for_test;
async fn create_config() -> (ContainerAsync<Redis>, DatabaseConfig) {
let container = Redis::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(6379)
.await
.unwrap_or(6379);
let config = DatabaseConfig {
name: "Test Redis".to_string(),
database: "redis".to_string(),
username: "".to_string(),
password: "".to_string(),
db_type: DbType::Redis,
port,
host: host.to_string(),
generated_id: "40875631-e3d2-4dfe-a26b-2a347ecc64fd".to_string(),
path: "".to_string(),
};
(container, config)
}
#[tokio::test]
async fn redis_ping_test() {
init_tracing_for_test();
let (_container, config) = create_config().await;
let db = DatabaseFactory::create_for_backup(config.clone()).await;
let reachable = db.ping().await.unwrap_or(false);
assert!(reachable);
}
#[tokio::test]
async fn redis_backup_test() {
init_tracing_for_test();
let (_container, config) = create_config().await;
let temp_dir = TempDir::new().unwrap();
let backup_path = temp_dir.path();
let db = DatabaseFactory::create_for_backup(config.clone()).await;
let file_path = db.backup(backup_path, Some(true)).await.unwrap();
assert!(file_path.is_file());
}
+64
View File
@@ -0,0 +1,64 @@
use tempfile::TempDir;
use testcontainers::runners::AsyncRunner;
use testcontainers::ContainerAsync;
use testcontainers_modules::valkey::{Valkey};
use url::Host;
use crate::domain::factory::DatabaseFactory;
use crate::services::config::{DatabaseConfig, DbType};
use crate::tests::init_tracing_for_test;
async fn create_config() -> (ContainerAsync<Valkey>, DatabaseConfig) {
let container = Valkey::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(6379)
.await
.unwrap_or(6379);
let config = DatabaseConfig {
name: "Test Valkey".to_string(),
database: "valkey".to_string(),
username: "".to_string(),
password: "".to_string(),
db_type: DbType::Valkey,
port,
host: host.to_string(),
generated_id: "40875485-e3d2-4dfe-a26b-2a347ecc64fd".to_string(),
path: "".to_string(),
};
(container, config)
}
#[tokio::test]
async fn valkey_ping_test() {
init_tracing_for_test();
let (_container, config) = create_config().await;
let db = DatabaseFactory::create_for_backup(config.clone()).await;
let reachable = db.ping().await.unwrap_or(false);
assert!(reachable);
}
#[tokio::test]
async fn valkey_backup_test() {
init_tracing_for_test();
let (_container, config) = create_config().await;
let temp_dir = TempDir::new().unwrap();
let backup_path = temp_dir.path();
let db = DatabaseFactory::create_for_backup(config.clone()).await;
let file_path = db.backup(backup_path, Some(true)).await.unwrap();
assert!(file_path.is_file());
}
+18
View File
@@ -0,0 +1,18 @@
mod utils;
mod domain;
use once_cell::sync::Lazy;
use tracing_subscriber;
static TRACING: Lazy<()> = Lazy::new(|| {
let _ = tracing_subscriber::fmt()
.with_test_writer()
.with_env_filter("debug")
.try_init();
});
fn init_tracing_for_test() -> () {
Lazy::force(&TRACING);
}
+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)
+21 -5
View File
@@ -1,5 +1,7 @@
use serde::{Deserialize, Deserializer};
use toml::Value;
use serde_json::{Value as ValueJson, };
pub fn deserialize_snake_case<'de, D>(deserializer: D) -> Result<Value, D::Error>
where
@@ -8,7 +10,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 +19,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() {
@@ -37,3 +38,18 @@ fn camel_to_snake(s: &str) -> String {
}
out
}
pub fn string_or_number_to_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<ValueJson>::deserialize(deserializer)?;
match value {
Some(ValueJson::String(s)) => Ok(Some(s)),
Some(ValueJson::Number(n)) => Ok(Some(n.to_string())),
Some(_) => Err(serde::de::Error::custom("port must be string or number")),
None => Ok(None),
}
}