mirror of
https://github.com/Portabase/agent.git
synced 2026-09-11 02:27:10 +00:00
Compare commits
78 Commits
1.1.6-rc.1
...
1.6.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e8ebb5920 | |||
| 8489a6f6e3 | |||
| 3f5b9c91cc | |||
| 1c52f298d2 | |||
| e602c3540f | |||
| b5e8ccac1f | |||
| f14a6dc318 | |||
| e1bc77e481 | |||
| cbc7517603 | |||
| fc4e4e3881 | |||
| 5ec7bc9f28 | |||
| 45c33dd031 | |||
| cddb9d78fa | |||
| cf5de3115c | |||
| d6d2ef18f8 | |||
| 987c630c73 | |||
| e9147d6c60 | |||
| 174ead72a3 | |||
| f2a67b3490 | |||
| d4afefbc1e | |||
| be6e147df2 | |||
| 91b6658852 | |||
| f7dcb6b2bb | |||
| ab14f0dcf0 | |||
| 58a0b92710 | |||
| 2f24291c0e | |||
| 1fdea31430 | |||
| d1d633fd00 | |||
| b1c14f098b | |||
| 3abb955e24 | |||
| 6d22be9b50 | |||
| c2df4ba71e | |||
| 46b3f2466a | |||
| a2be03751f | |||
| 7df9df1605 | |||
| df5b16a153 | |||
| 824a5d52a3 | |||
| 1496208db4 | |||
| 454e1b442f | |||
| 54bca7452a | |||
| a96cef6cb5 | |||
| fd9f183166 | |||
| 94fb2535ec | |||
| 189ad866de | |||
| 55bbd4e724 | |||
| 7fabc9c98c | |||
| 4f67a57681 | |||
| 45a1118f64 | |||
| 063b2e1c2c | |||
| f2275d5aca | |||
| b136140b55 | |||
| 924177a4fe | |||
| 773077f66b | |||
| 46ff086cf9 | |||
| 678df8e2bc | |||
| f563c187db | |||
| df0dffa47b | |||
| 864396422b | |||
| e280c1d2f8 | |||
| 91a9d0654b | |||
| bc857a4c18 | |||
| 2454915866 | |||
| a4a7a9a2c4 | |||
| 997ba2afa2 | |||
| 7e0c7c0688 | |||
| f973bc1f52 | |||
| b9b9fcf6ee | |||
| 0224605c59 | |||
| 6ea9fdf8bc | |||
| ad68be8a5a | |||
| 434c131afb | |||
| 675ac5976d | |||
| 70543fe032 | |||
| 77f90308b4 | |||
| 63cb254abd | |||
| 7738745def | |||
| 82659f681c | |||
| eee3bebd50 |
@@ -0,0 +1,23 @@
|
||||
reviews:
|
||||
auto_review:
|
||||
enabled: true
|
||||
ignore_usernames: ["dependabot[bot]"]
|
||||
labels: ["!wip", "!draft"]
|
||||
auto_apply_labels: true
|
||||
suggested_reviewers: true
|
||||
auto_assign_reviewers: true
|
||||
|
||||
issue_enrichment:
|
||||
auto_enrich:
|
||||
enabled: true
|
||||
labeling:
|
||||
auto_apply_labels: true
|
||||
labeling_instructions:
|
||||
- label: bug
|
||||
instructions: "Error, crash, or incorrect behavior reports"
|
||||
- label: enhancement
|
||||
instructions: "Feature requests or improvements"
|
||||
- label: documentation
|
||||
instructions: "Docs updates or missing documentation"
|
||||
- label: good first issue
|
||||
instructions: "Beginner-friendly tasks suitable for new contributors"
|
||||
@@ -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 }}
|
||||
@@ -0,0 +1,73 @@
|
||||
name: Discord Notification
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
release_tag:
|
||||
required: true
|
||||
type: string
|
||||
discord_title:
|
||||
required: true
|
||||
type: string
|
||||
discord_color:
|
||||
required: true
|
||||
type: number
|
||||
discord_footer:
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
DISCORD_WEBHOOK:
|
||||
required: true
|
||||
GH_TOKEN:
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
notify-discord:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send Discord Notification
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
run: |
|
||||
RELEASE_INFO=$(gh release view "${{ inputs.release_tag }}" -R ${{ github.repository }} --json name,url,body,author)
|
||||
|
||||
RELEASE_TITLE=$(echo "$RELEASE_INFO" | jq -r .name)
|
||||
if [ -z "$RELEASE_TITLE" ] || [ "$RELEASE_TITLE" = "null" ]; then RELEASE_TITLE="${{ inputs.release_tag }}"; fi
|
||||
|
||||
RELEASE_URL=$(echo "$RELEASE_INFO" | jq -r .url)
|
||||
RELEASE_BODY=$(echo "$RELEASE_INFO" | jq -r .body)
|
||||
|
||||
AUTHOR_NAME="Portabase"
|
||||
AUTHOR_ICON="https://github.com/Portabase.png"
|
||||
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg title "$RELEASE_TITLE" \
|
||||
--arg description "$RELEASE_BODY" \
|
||||
--arg url "$RELEASE_URL" \
|
||||
--arg author "$AUTHOR_NAME" \
|
||||
--arg icon "$AUTHOR_ICON" \
|
||||
--arg discord_title "${{ inputs.discord_title }}" \
|
||||
--arg discord_footer "${{ inputs.discord_footer }}" \
|
||||
--argjson discord_color ${{ inputs.discord_color }} \
|
||||
'{
|
||||
content: $discord_title,
|
||||
embeds: [{
|
||||
title: $title,
|
||||
url: $url,
|
||||
description: $description,
|
||||
color: $discord_color,
|
||||
author: {
|
||||
name: $author,
|
||||
icon_url: $icon
|
||||
},
|
||||
footer: {
|
||||
text: $discord_footer
|
||||
}
|
||||
}]
|
||||
}'
|
||||
)
|
||||
|
||||
curl -H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" \
|
||||
"$DISCORD_WEBHOOK"
|
||||
@@ -3,10 +3,16 @@ name: Docker Publish
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
required: true
|
||||
type: string
|
||||
ref:
|
||||
required: true
|
||||
type: string
|
||||
image_name:
|
||||
required: false
|
||||
type: string
|
||||
default: 'portabase/agent'
|
||||
default: "portabase/agent"
|
||||
add_latest:
|
||||
required: false
|
||||
type: boolean
|
||||
@@ -14,11 +20,11 @@ on:
|
||||
target:
|
||||
required: false
|
||||
type: string
|
||||
default: 'prod'
|
||||
default: "prod"
|
||||
dockerfile:
|
||||
required: false
|
||||
type: string
|
||||
default: './docker/Dockerfile'
|
||||
default: "./docker/Dockerfile"
|
||||
secrets:
|
||||
DOCKER_USERNAME:
|
||||
required: true
|
||||
@@ -27,32 +33,34 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build and push Docker images
|
||||
runs-on: ${{ matrix.platform == 'linux/amd64' && 'ubuntu-latest' || matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' }}
|
||||
name: Build architectures
|
||||
runs-on: ${{ matrix.platform == 'linux/amd64' && 'ubuntu-latest' || 'ubuntu-24.04-arm' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform: [linux/amd64, linux/arm64]
|
||||
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
|
||||
|
||||
- name: Login to Docker
|
||||
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Set image tag
|
||||
id: set-tags
|
||||
- name: Prepare Image Tags
|
||||
id: prep
|
||||
run: |
|
||||
REF_NAME=${GITHUB_REF#refs/tags/}
|
||||
if [ "${{ matrix.platform }}" = "linux/amd64" ]; then
|
||||
IMAGE="${{ inputs.image_name }}:$REF_NAME-amd64"
|
||||
else
|
||||
IMAGE="${{ inputs.image_name }}:$REF_NAME-arm64"
|
||||
fi
|
||||
echo "image=$IMAGE" >> $GITHUB_OUTPUT
|
||||
ARCH=${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
|
||||
echo "image=${{ inputs.image_name }}:${{inputs.version}}-$ARCH" >> $GITHUB_OUTPUT
|
||||
echo "safe_platform=${{ matrix.platform == 'linux/amd64' && 'linux-amd64' || 'linux-arm64' }}" >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -61,57 +69,71 @@ jobs:
|
||||
file: ${{ inputs.dockerfile }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: true
|
||||
tags: ${{ steps.set-tags.outputs.image }}
|
||||
tags: ${{ steps.prep.outputs.image }}
|
||||
target: ${{ inputs.target }}
|
||||
|
||||
- name: Prepare artifact name
|
||||
id: artifact
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "safe_platform=${platform//\//-}" >> $GITHUB_OUTPUT
|
||||
echo "${{ steps.set-tags.outputs.image }}" > image.txt
|
||||
- name: Save image name for manifest
|
||||
run: echo "${{ steps.prep.outputs.image }}" > image.txt
|
||||
|
||||
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: image-${{ steps.artifact.outputs.safe_platform }}
|
||||
name: image-${{ steps.prep.outputs.safe_platform }}
|
||||
path: image.txt
|
||||
if-no-files-found: warn
|
||||
compression-level: 6
|
||||
overwrite: false
|
||||
include-hidden-files: false
|
||||
retention-days: 1
|
||||
|
||||
create-manifest:
|
||||
name: Create multi-arch Docker manifest
|
||||
name: Create multi-arch manifest
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: image-linux-amd64
|
||||
path: /tmp/digests/amd64
|
||||
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: image-linux-arm64
|
||||
path: /tmp/digests/arm64
|
||||
|
||||
- name: Login to Docker
|
||||
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Generate semantic tags
|
||||
id: tags
|
||||
run: |
|
||||
VERSION="${{ inputs.version }}"
|
||||
IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION"
|
||||
|
||||
echo "VERSION_TAG=$VERSION" >> $GITHUB_OUTPUT
|
||||
if [ -n "$MINOR" ]; then
|
||||
echo "MINOR_TAG=$MAJOR.$MINOR" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
if [ -n "$MAJOR" ]; then
|
||||
echo "MAJOR_TAG=$MAJOR" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
if [ "${{ inputs.add_latest }}" = "true" ]; then
|
||||
echo "LATEST_TAG=latest" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ inputs.image_name }}
|
||||
tags: |
|
||||
type=raw,value=${{ steps.tags.outputs.VERSION_TAG }}
|
||||
type=raw,value=${{ steps.tags.outputs.MINOR_TAG }}
|
||||
type=raw,value=${{ steps.tags.outputs.MAJOR_TAG }}
|
||||
type=raw,value=${{ steps.tags.outputs.LATEST_TAG }}
|
||||
|
||||
- name: Create and push manifest list
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
DOCKER_IMAGES="$(cat amd64/image.txt) $(cat arm64/image.txt)"
|
||||
REF_NAME=${GITHUB_REF#refs/tags/}
|
||||
MANIFEST_IMAGE="${{ inputs.image_name }}:$REF_NAME"
|
||||
|
||||
docker buildx imagetools create $DOCKER_IMAGES -t $MANIFEST_IMAGE
|
||||
docker buildx imagetools inspect $MANIFEST_IMAGE
|
||||
|
||||
if [ "${{ inputs.add_latest }}" = "true" ]; then
|
||||
docker buildx imagetools create $DOCKER_IMAGES -t ${{ inputs.image_name }}:latest
|
||||
docker buildx imagetools inspect ${{ inputs.image_name }}:latest
|
||||
fi
|
||||
TAG_ARGS=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON")
|
||||
echo $TAG_ARGS
|
||||
docker buildx imagetools create $TAG_ARGS $DOCKER_IMAGES
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
name: GitHub Release
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
prerelease:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
make_latest:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
discord_title:
|
||||
required: true
|
||||
type: string
|
||||
discord_color:
|
||||
required: true
|
||||
type: number
|
||||
discord_footer:
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
DISCORD_WEBHOOK:
|
||||
required: true
|
||||
GH_TOKEN:
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Build Changelog
|
||||
id: build_changelog
|
||||
uses: mikepenz/release-changelog-builder-action@v5
|
||||
with:
|
||||
mode: "COMMIT"
|
||||
configurationJson: |
|
||||
{
|
||||
"template": "#{{CHANGELOG}}",
|
||||
"categories": [
|
||||
{
|
||||
"title": "## Feature",
|
||||
"labels": ["feat", "feature"]
|
||||
},
|
||||
{
|
||||
"title": "## Fix",
|
||||
"labels": ["fix", "bug"]
|
||||
},
|
||||
{
|
||||
"title": "## Other",
|
||||
"labels": []
|
||||
}
|
||||
],
|
||||
"label_extractor": [
|
||||
{
|
||||
"pattern": "^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test){1}(\([\w\-\.]+\))?(!)?: ([\w ])+([\s\S]*)",
|
||||
"on_property": "title",
|
||||
"target": "$1"
|
||||
}
|
||||
]
|
||||
}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: false
|
||||
body: ${{ steps.build_changelog.outputs.changelog }}
|
||||
prerelease: ${{ inputs.prerelease }}
|
||||
make_latest: ${{ inputs.make_latest }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
- name: Send Discord Notification
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
run: |
|
||||
RELEASE_INFO=$(gh release view "${{ github.ref_name }}" -R ${{ github.repository }} --json name,url,body,author)
|
||||
|
||||
RELEASE_TITLE=$(echo "$RELEASE_INFO" | jq -r .name)
|
||||
if [ -z "$RELEASE_TITLE" ] || [ "$RELEASE_TITLE" = "null" ]; then RELEASE_TITLE="${{ github.ref_name }}"; fi
|
||||
|
||||
RELEASE_URL=$(echo "$RELEASE_INFO" | jq -r .url)
|
||||
RELEASE_BODY=$(echo "$RELEASE_INFO" | jq -r .body)
|
||||
|
||||
AUTHOR_NAME="Portabase"
|
||||
AUTHOR_ICON="https://github.com/Portabase.png"
|
||||
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg title "$RELEASE_TITLE" \
|
||||
--arg description "$RELEASE_BODY" \
|
||||
--arg url "$RELEASE_URL" \
|
||||
--arg author "$AUTHOR_NAME" \
|
||||
--arg icon "$AUTHOR_ICON" \
|
||||
--arg discord_title "${{ inputs.discord_title }}" \
|
||||
--arg discord_footer "${{ inputs.discord_footer }}" \
|
||||
--argjson discord_color ${{ inputs.discord_color }} \
|
||||
'{
|
||||
content: $discord_title,
|
||||
embeds: [{
|
||||
title: $title,
|
||||
url: $url,
|
||||
description: $description,
|
||||
color: $discord_color,
|
||||
author: {
|
||||
name: $author,
|
||||
icon_url: $icon
|
||||
},
|
||||
footer: {
|
||||
text: $discord_footer
|
||||
}
|
||||
}]
|
||||
}'
|
||||
)
|
||||
|
||||
curl -H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" \
|
||||
"$DISCORD_WEBHOOK"
|
||||
@@ -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
|
||||
+128
-12
@@ -1,32 +1,148 @@
|
||||
name: Publish Docker image for release
|
||||
name: Auto Release & Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*.*.*'
|
||||
- '!*-*'
|
||||
pull_request:
|
||||
types: [ closed ]
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
docker_publish:
|
||||
|
||||
check-skip:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
skip: ${{ steps.set-skip.outputs.skip }}
|
||||
steps:
|
||||
- name: Determine if release should be skipped
|
||||
id: set-skip
|
||||
run: |
|
||||
TITLE="${{ github.event.pull_request.title }}"
|
||||
echo "PR title: $TITLE"
|
||||
|
||||
if [[ "$TITLE" == *"[skip-release]"* ]]; then
|
||||
echo "PR title contains [skip-release], skipping release jobs."
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
create-release:
|
||||
needs: check-skip
|
||||
if: ${{ needs.check-skip.outputs.skip == 'false' }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
draft_tag: ${{ steps.release_step.outputs.draft_tag }}
|
||||
version: ${{ steps.release_step.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ vars.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
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/*"
|
||||
|
||||
- name: Install release-it globally
|
||||
run: |
|
||||
npm install -g release-it
|
||||
npm install -g @release-it/conventional-changelog
|
||||
npm install -g @release-it/bumper
|
||||
|
||||
- run: |
|
||||
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 }}
|
||||
|
||||
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 }}
|
||||
|
||||
github_release:
|
||||
needs: docker_publish
|
||||
uses: ./.github/workflows/github.yml
|
||||
publish-helm:
|
||||
needs: create-release
|
||||
if: ${{ needs.create-release.result == 'success' }}
|
||||
uses: ./.github/workflows/helm.yml
|
||||
with:
|
||||
prerelease: false
|
||||
make_latest: true
|
||||
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 }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Publish GitHub Release
|
||||
id: publish_release_step
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
OUTPUT=$(gh release edit ${{ needs.create-release.outputs.draft_tag }} --draft=false)
|
||||
echo "$OUTPUT"
|
||||
RELEASE_TAG=$(echo "$OUTPUT" | sed -E 's|.*/releases/tag/||')
|
||||
echo "release_tag=$RELEASE_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
notify-discord:
|
||||
needs:
|
||||
- publish-docker
|
||||
- publish-helm
|
||||
- create-release
|
||||
- finalize-release
|
||||
uses: ./.github/workflows/discord.yml
|
||||
with:
|
||||
release_tag: ${{ needs.create-release.outputs.version }}
|
||||
discord_title: "||@release-agent|| New release published"
|
||||
discord_color: 5814783
|
||||
discord_color: 3066993
|
||||
discord_footer: "Portabase"
|
||||
secrets:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"github": {
|
||||
"release": true,
|
||||
"draft": true,
|
||||
"tokenRef": "GITHUB_TOKEN"
|
||||
},
|
||||
"git": {
|
||||
"commit": true,
|
||||
"commitMessage": "chore: release ${version}",
|
||||
"requireCleanWorkingDir": true,
|
||||
"tag": true,
|
||||
"tagName": "${version}",
|
||||
"push": true
|
||||
},
|
||||
"hooks": {
|
||||
"before:bump": "cargo set-version ${version}"
|
||||
},
|
||||
"plugins": {
|
||||
"@release-it/conventional-changelog": {
|
||||
"preset": {
|
||||
"name": "conventionalcommits",
|
||||
"types": [
|
||||
{
|
||||
"type": "feat",
|
||||
"section": "✨ Features"
|
||||
},
|
||||
{
|
||||
"type": "fix",
|
||||
"section": "🐛 Bug Fixes"
|
||||
},
|
||||
{
|
||||
"type": "perf",
|
||||
"section": "⚡️ Performance Improvements"
|
||||
},
|
||||
{
|
||||
"type": "revert",
|
||||
"section": "⏪️ Reverts"
|
||||
},
|
||||
{
|
||||
"type": "docs",
|
||||
"section": "📝 Documentation",
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"type": "chore",
|
||||
"section": "🔧 Chores",
|
||||
"hidden": true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"@release-it/bumper": {
|
||||
"out": [
|
||||
{
|
||||
"file": "CITATION.cff",
|
||||
"path": "version",
|
||||
"type": "text/yaml"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-4
@@ -1,6 +1,6 @@
|
||||
cff-version: 1.2.0
|
||||
title: Portabase Agent (Rust)
|
||||
message: "If you use this software, please cite it as below."
|
||||
message: If you use this software, please cite it as below.
|
||||
type: software
|
||||
authors:
|
||||
- family-names: Gauthereau
|
||||
@@ -9,7 +9,12 @@ authors:
|
||||
given-names: Killian
|
||||
repository-code: https://github.com/Portabase/agent-rust
|
||||
url: https://portabase.io
|
||||
abstract: "Portabase is a free, open-source, self-hosted solution for database administration, providing backup and restore capabilities, scheduling, retention policies, notifications, and support for multiple storage backends. Its headless agent architecture enables connection to multiple database instances securely and efficiently."
|
||||
abstract: >-
|
||||
Portabase is a free, open-source, self-hosted solution for database
|
||||
administration, providing backup and restore capabilities, scheduling,
|
||||
retention policies, notifications, and support for multiple storage backends.
|
||||
Its headless agent architecture enables connection to multiple database
|
||||
instances securely and efficiently.
|
||||
keywords:
|
||||
- database
|
||||
- administration
|
||||
@@ -22,5 +27,5 @@ keywords:
|
||||
- self-hosted
|
||||
- portabase
|
||||
license: Apache-2.0
|
||||
version: 1.1.6-rc.1
|
||||
date-released: "2026-02-23"
|
||||
version: 1.6.2
|
||||
date-released: '2026-02-24'
|
||||
|
||||
Generated
+1144
-907
File diff suppressed because it is too large
Load Diff
+17
-3
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "portabase-agent"
|
||||
version = "1.1.6-rc.1"
|
||||
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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,9 +16,11 @@
|
||||
[](https://github.com/Portabase/portabase)
|
||||
[](https://www.buymeacoffee.com/portabase)
|
||||
|
||||
[](https://www.python.org/downloads/release/python-3120/)
|
||||
[](https://www.postgresql.org/)
|
||||
[](https://www.mysql.com/)
|
||||
[](https://sqlite.org/)
|
||||
[](https://redis.io/)
|
||||
[](https://valkey.io/)
|
||||
[](https://mariadb.org/)
|
||||
[](https://www.mongodb.com/)
|
||||
[](https://github.com/Portabase/portabase)
|
||||
@@ -79,3 +81,5 @@ Distributed under the Apache License. See `LICENSE.txt` for more information.
|
||||
|
||||
[Docker-url]: https://www.docker.com/
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -43,6 +43,44 @@
|
||||
"type": "sqlite",
|
||||
"path": "/sqlite-data/workspace/data/app.db",
|
||||
"generated_id": "16678178-ff7e-4c97-8c83-0adeff214681"
|
||||
},
|
||||
{
|
||||
"name": "Test database 7 - SQLite DB",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+110
-60
@@ -11,13 +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
|
||||
# - 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: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiZjg4Y2E0MDMtNDgwOS00NGM4LTlkZjItY2VkNWYwYzhkNTM2IiwibWFzdGVyS2V5QjY0IjoiQlhWM1hvbEM2NTZTVjdkTmdjV1BHUWxrKytycExJNmxHRGk3Q1BCNWllbz0ifQ=="
|
||||
EDGE_KEY: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiNWU1OGU2MGEtODhiMy00YTBjLWI0NDktNTQ3OWZhOTQzZDBkIiwibWFzdGVyS2V5QjY0IjoiQlhWM1hvbEM2NTZTVjdkTmdjV1BHUWxrKytycExJNmxHRGk3Q1BCNWllbz0ifQ=="
|
||||
#POOLING: 1
|
||||
#DATABASES_CONFIG_FILE: "config.toml"
|
||||
extra_hosts:
|
||||
@@ -38,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
|
||||
# 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:
|
||||
- "3311:3306"
|
||||
environment:
|
||||
- MYSQL_DATABASE=mariadb
|
||||
- MYSQL_USER=mariadb
|
||||
- MYSQL_PASSWORD=changeme
|
||||
- MYSQL_RANDOM_ROOT_PASSWORD=yes
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- mariadb-data:/var/lib/mysql
|
||||
- redis-data:/data
|
||||
command: [ "redis-server", "--appendonly", "yes" ]
|
||||
networks:
|
||||
- portabase
|
||||
|
||||
|
||||
db-mongodb-auth:
|
||||
container_name: db-mongodb-auth
|
||||
image: mongo:latest
|
||||
db-redis-auth:
|
||||
image: redis:latest
|
||||
container_name: db-redis-auth
|
||||
ports:
|
||||
- "27082:27017"
|
||||
environment:
|
||||
MONGO_INITDB_ROOT_USERNAME: root
|
||||
MONGO_INITDB_ROOT_PASSWORD: rootpassword
|
||||
MONGO_INITDB_DATABASE: testdbauth
|
||||
command: mongod --auth
|
||||
networks:
|
||||
- portabase
|
||||
- "6380:6379"
|
||||
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
|
||||
- redis-data-auth:/data
|
||||
environment:
|
||||
MONGO_INITDB_DATABASE: testdb
|
||||
- REDIS_PASSWORD=supersecurepassword
|
||||
command: [ "redis-server", "--requirepass", "supersecurepassword", "--appendonly", "yes" ]
|
||||
networks:
|
||||
- portabase
|
||||
|
||||
sqlite:
|
||||
container_name: db-sqlite
|
||||
image: keinos/sqlite3
|
||||
db-valkey:
|
||||
image: valkey/valkey
|
||||
container_name: db-valkey
|
||||
environment:
|
||||
- ALLOW_EMPTY_PASSWORD=yes
|
||||
ports:
|
||||
- '6381:6379'
|
||||
volumes:
|
||||
- sqlite-data:/workspace/data
|
||||
working_dir: /workspace
|
||||
command: tail -f /dev/null
|
||||
stdin_open: true
|
||||
tty: true
|
||||
- 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
@@ -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/*
|
||||
|
||||
|
||||
|
||||
Executable
+39
@@ -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
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
.DS_Store
|
||||
.git/
|
||||
.gitignore
|
||||
.bzr/
|
||||
.bzrignore
|
||||
.hg/
|
||||
.hgignore
|
||||
.svn/
|
||||
*.swp
|
||||
*.bak
|
||||
*.tmp
|
||||
*.orig
|
||||
*~
|
||||
.project
|
||||
.idea/
|
||||
*.tmproj
|
||||
.vscode/
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,7 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: portabase-agent-config
|
||||
data:
|
||||
config.json: |
|
||||
{{ .Values.volume.configFile.content | nindent 4 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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"
|
||||
@@ -1,99 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
if [ -z "$1" ]; then
|
||||
echo "Usage: ./release <version>"
|
||||
echo "Example: ./release v1.0.0"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION=$1
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
|
||||
if [ "$CURRENT_BRANCH" = "main" ]; then
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Error: On 'main' branch, only release tags (X.Y.Z) are allowed."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
|
||||
echo "Error: On branch '$CURRENT_BRANCH', only RC tags matching X.Y.Z-rc.W are allowed (e.g., 1.0.0-rc.1)."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
CLEAN_VERSION=${VERSION#v}
|
||||
CURRENT_DATE=$(date +%Y-%m-%d)
|
||||
|
||||
echo "Preparing release $VERSION..."
|
||||
|
||||
|
||||
# package.json
|
||||
if [ -f package.json ]; then
|
||||
echo "Updating package.json..."
|
||||
if sed --version >/dev/null 2>&1; then
|
||||
sed -i "s/\"version\": \".*\"/\"version\": \"$CLEAN_VERSION\"/" package.json
|
||||
else
|
||||
sed -i '' "s/\"version\": \".*\"/\"version\": \"$CLEAN_VERSION\"/" package.json
|
||||
fi
|
||||
fi
|
||||
|
||||
# pyproject.toml
|
||||
if [ -f pyproject.toml ]; then
|
||||
echo "Updating pyproject.toml..."
|
||||
if sed --version >/dev/null 2>&1; then
|
||||
sed -i "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" pyproject.toml
|
||||
else
|
||||
sed -i '' "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" pyproject.toml
|
||||
fi
|
||||
fi
|
||||
|
||||
# Cargo.toml
|
||||
if [ -f Cargo.toml ]; then
|
||||
echo "Updating Cargo.toml..."
|
||||
if sed --version >/dev/null 2>&1; then
|
||||
sed -i "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" Cargo.toml
|
||||
else
|
||||
sed -i '' "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" Cargo.toml
|
||||
fi
|
||||
fi
|
||||
|
||||
# CITATION.cff
|
||||
if [ -f CITATION.cff ]; then
|
||||
echo "Updating CITATION.cff..."
|
||||
if sed --version >/dev/null 2>&1; then
|
||||
sed -i "s/^version: .*/version: $CLEAN_VERSION/" CITATION.cff
|
||||
sed -i "s/^date-released: .*/date-released: \"$CURRENT_DATE\"/" CITATION.cff
|
||||
else
|
||||
sed -i '' "s/^version: .*/version: $CLEAN_VERSION/" CITATION.cff
|
||||
sed -i '' "s/^date-released: .*/date-released: \"$CURRENT_DATE\"/" CITATION.cff
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
git add .
|
||||
|
||||
if ! git diff-index --quiet HEAD --; then
|
||||
echo "Committing changes..."
|
||||
git commit -m "chore(release): $VERSION"
|
||||
else
|
||||
echo "No changes to commit. Proceeding to tag..."
|
||||
fi
|
||||
|
||||
if git rev-parse "$VERSION" >/dev/null 2>&1; then
|
||||
echo "Tag $VERSION already exists. Aborting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Creating tag $VERSION..."
|
||||
git tag -a "$VERSION" -m "Release $VERSION"
|
||||
|
||||
echo "Pushing changes and tags to remote..."
|
||||
git push
|
||||
git push origin "$VERSION"
|
||||
|
||||
echo "Successfully released $VERSION!"
|
||||
@@ -60,7 +60,7 @@ SELECT
|
||||
)
|
||||
FROM users u
|
||||
JOIN generate_series(1, 30) AS p(post_no)
|
||||
ON u.id <= 300000;
|
||||
ON u.id <= 500000;
|
||||
|
||||
-- ============================================================
|
||||
-- OPTIONAL: FORCE DISK MATERIALIZATION
|
||||
|
||||
Binary file not shown.
Executable
+88
@@ -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
@@ -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
@@ -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))
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,4 +3,6 @@ pub mod postgres;
|
||||
pub mod mysql;
|
||||
mod mongodb;
|
||||
mod sqlite;
|
||||
mod redis;
|
||||
mod valkey;
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ pub fn select_mongo_path() -> std::path::PathBuf {
|
||||
|
||||
pub fn get_mongo_uri(cfg: DatabaseConfig) -> Result<String> {
|
||||
|
||||
if cfg.username.is_empty() && cfg.password.is_empty() {
|
||||
if cfg.username.is_empty() || cfg.password.is_empty() {
|
||||
Ok(format!("mongodb://{}:{}/{}", cfg.host, cfg.port, cfg.database))
|
||||
} else {
|
||||
Ok(format!(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mod backup;
|
||||
pub mod database;
|
||||
mod ping;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mod backup;
|
||||
pub mod database;
|
||||
mod ping;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,2 +1,3 @@
|
||||
pub mod status;
|
||||
pub mod backup;
|
||||
pub mod backup;
|
||||
pub mod restore;
|
||||
@@ -0,0 +1,33 @@
|
||||
use crate::services::api::models::agent::restore::ResultRestoreResponse;
|
||||
use crate::services::api::{ApiClient, ApiError};
|
||||
use anyhow::Result;
|
||||
use reqwest::Method;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ResultRestoreRequest {
|
||||
#[serde(rename = "generatedId")]
|
||||
pub generated_id: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
impl ApiClient {
|
||||
pub async fn restore_result(
|
||||
&self,
|
||||
agent_id: impl Into<String>,
|
||||
generated_id: impl Into<String>,
|
||||
status: impl Into<String>,
|
||||
) -> Result<Option<ResultRestoreResponse>, ApiError> {
|
||||
let body = ResultRestoreRequest {
|
||||
generated_id: generated_id.into(),
|
||||
status: status.into(),
|
||||
};
|
||||
|
||||
let agent_id = agent_id.into();
|
||||
|
||||
let path = format!("/agent/{}/restore", agent_id);
|
||||
|
||||
self.request_with_body(Method::POST, path.as_str(), &body)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod status;
|
||||
pub mod backup;
|
||||
pub mod backup;
|
||||
pub mod restore;
|
||||
@@ -0,0 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ResultRestoreResponse {
|
||||
pub message: String,
|
||||
pub status: bool,
|
||||
}
|
||||
@@ -1,415 +0,0 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::core::context::Context as CoreContext;
|
||||
use crate::domain::factory::DatabaseFactory;
|
||||
use crate::services::api::models::agent::status::DatabaseStorage;
|
||||
use crate::services::config::{DatabaseConfig, DatabasesConfig, DbType};
|
||||
use crate::services::storage;
|
||||
use crate::utils::common::BackupMethod;
|
||||
use crate::utils::compress::compress_to_tar_gz_large;
|
||||
use anyhow::Result;
|
||||
use futures::future::join_all;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
use tracing::{error, info};
|
||||
use crate::utils::locks::FileLock;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BackupResult {
|
||||
pub generated_id: String,
|
||||
pub db_type: DbType,
|
||||
pub status: String,
|
||||
pub backup_file: Option<PathBuf>,
|
||||
pub code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UploadResult {
|
||||
pub storage_id: String,
|
||||
pub success: bool,
|
||||
pub error: Option<String>,
|
||||
pub remote_file_path: Option<String>,
|
||||
pub total_size: Option<u64>,
|
||||
}
|
||||
|
||||
pub struct BackupService {
|
||||
ctx: Arc<CoreContext>,
|
||||
}
|
||||
|
||||
impl BackupService {
|
||||
pub fn new(ctx: Arc<CoreContext>) -> Self {
|
||||
Self { ctx }
|
||||
}
|
||||
|
||||
pub async fn dispatch(
|
||||
&self,
|
||||
generated_id: &String,
|
||||
config: &DatabasesConfig,
|
||||
method: BackupMethod,
|
||||
storages: &Vec<DatabaseStorage>,
|
||||
encrypt: bool,
|
||||
) {
|
||||
if let Some(cfg) = config
|
||||
.databases
|
||||
.iter()
|
||||
.find(|c| c.generated_id == generated_id.as_str())
|
||||
{
|
||||
let db_cfg = cfg.clone();
|
||||
let ctx = self.ctx.clone();
|
||||
let storages_clone = storages.clone();
|
||||
let generated_id_clone = generated_id.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
match TempDir::new() {
|
||||
Ok(temp_dir) => {
|
||||
match FileLock::is_locked(&generated_id_clone).await {
|
||||
Ok(true) => {
|
||||
error!("Backup already running for {}", &generated_id_clone);
|
||||
return;
|
||||
}
|
||||
Ok(false) => {
|
||||
match ctx
|
||||
.api
|
||||
.backup_create(
|
||||
method.clone().to_string(),
|
||||
ctx.edge_key.agent_id.clone(),
|
||||
&generated_id_clone,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(backup_created_result) => {
|
||||
info!("Backup created successfully");
|
||||
let tmp_path = temp_dir.path().to_path_buf();
|
||||
info!("Created temp directory {}", tmp_path.display());
|
||||
match BackupService::run(db_cfg, &tmp_path).await {
|
||||
Ok(mut result) => {
|
||||
let backup_id = backup_created_result.unwrap().backup.id;
|
||||
|
||||
if result.status == "failed" {
|
||||
error!("Backup failed early for {}", result.generated_id);
|
||||
let service = BackupService { ctx: ctx.clone() };
|
||||
let _ = service
|
||||
.send_result(result, vec![], &backup_id)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if let Some(backup_file) = result.backup_file.take() {
|
||||
match compress_to_tar_gz_large(&backup_file).await {
|
||||
Ok(compression_result) => {
|
||||
result.backup_file =
|
||||
Some(compression_result.compressed_path);
|
||||
let service = BackupService { ctx: ctx.clone() };
|
||||
match service
|
||||
.upload(
|
||||
result.clone(),
|
||||
method,
|
||||
storages_clone.clone(),
|
||||
encrypt,
|
||||
&backup_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(upload_result) => {
|
||||
match service
|
||||
.send_result(
|
||||
result,
|
||||
upload_result,
|
||||
&backup_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to send backup result: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to upload backup files: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to compress backup file : {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
error!("No backup file generated");
|
||||
}
|
||||
}
|
||||
Err(e) => error!("BackupService run failed: {}", e),
|
||||
}
|
||||
// TempDir is automatically deleted when dropped here
|
||||
}
|
||||
Err(e) => error!("Backup creation failed: {}", e),
|
||||
}
|
||||
}
|
||||
Err(e) => error!("An error occurred while checking lock : {}", e),
|
||||
}
|
||||
}
|
||||
Err(e) => error!("Failed to create temp dir: {}", e),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(cfg: DatabaseConfig, tmp_path: &Path) -> Result<BackupResult> {
|
||||
let db_instance = DatabaseFactory::create_for_backup(cfg.clone()).await;
|
||||
let generated_id = cfg.generated_id.clone();
|
||||
let db_type = cfg.db_type.clone();
|
||||
|
||||
|
||||
let reachable = match db_instance.ping().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
error!("Ping failed: {}", e);
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
info!("Reachable: {}", reachable);
|
||||
if !reachable {
|
||||
return Ok(BackupResult {
|
||||
generated_id,
|
||||
db_type,
|
||||
status: "failed".into(),
|
||||
backup_file: None,
|
||||
code: None,
|
||||
});
|
||||
}
|
||||
|
||||
match db_instance.backup(tmp_path).await {
|
||||
Ok(file) => Ok(BackupResult {
|
||||
generated_id,
|
||||
db_type,
|
||||
status: "success".into(),
|
||||
backup_file: Some(file),
|
||||
code: None,
|
||||
}),
|
||||
Err(e) => match e.to_string().as_str() {
|
||||
"backup_already_in_progress" => Ok(BackupResult {
|
||||
generated_id,
|
||||
db_type,
|
||||
status: "failed".into(),
|
||||
backup_file: None,
|
||||
code: Some(e.to_string()),
|
||||
}),
|
||||
_ => Ok(BackupResult {
|
||||
generated_id,
|
||||
db_type,
|
||||
status: "failed".into(),
|
||||
backup_file: None,
|
||||
code: None,
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn upload(
|
||||
&self,
|
||||
result: BackupResult,
|
||||
method: BackupMethod,
|
||||
storages: Vec<DatabaseStorage>,
|
||||
encrypt: bool,
|
||||
backup_id: &String,
|
||||
) -> Result<Vec<UploadResult>> {
|
||||
if result.code.as_deref() == Some("backup_already_in_progress") {
|
||||
info!("Skipping send: backup already in progress");
|
||||
anyhow::bail!("backup_already_in_progres");
|
||||
}
|
||||
|
||||
let upload_futures = storages.into_iter().map(|storage| {
|
||||
info!(
|
||||
"Uploading storage -> {:?} for {:?}",
|
||||
storage.provider, storage.id
|
||||
);
|
||||
let provider = storage::get_provider(&storage);
|
||||
let result_clone = result.clone();
|
||||
let ctx_clone = self.ctx.clone();
|
||||
let storages_clone = storage.clone();
|
||||
let storage_id = storages_clone.id;
|
||||
let generated_id = result_clone.generated_id.clone();
|
||||
|
||||
async move {
|
||||
match self
|
||||
.ctx
|
||||
.api
|
||||
.backup_upload_init(
|
||||
self.ctx.edge_key.agent_id.clone(),
|
||||
generated_id.clone(),
|
||||
storage_id.clone(),
|
||||
backup_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(upload_init_result) => {
|
||||
info!("Uploading init result: {:#?}", upload_init_result);
|
||||
let backup_storage_id = upload_init_result.unwrap().backup_storage.id.clone();
|
||||
match provider {
|
||||
Some(provider) => {
|
||||
let upload_result = provider
|
||||
.upload(
|
||||
ctx_clone,
|
||||
result_clone,
|
||||
method,
|
||||
&storage,
|
||||
Some(encrypt),
|
||||
)
|
||||
.await;
|
||||
|
||||
let status = if upload_result.success {
|
||||
"success"
|
||||
} else {
|
||||
"failed"
|
||||
};
|
||||
|
||||
if status != "success" {
|
||||
return upload_result;
|
||||
}
|
||||
|
||||
info!("Storage {} uploaded to remote path {:?}", storage_id, upload_result.remote_file_path);
|
||||
|
||||
let (remote_path, total_size) = match (
|
||||
&upload_result.remote_file_path,
|
||||
upload_result.total_size,
|
||||
) {
|
||||
(Some(path), Some(size)) => (path.clone(), size),
|
||||
_ => {
|
||||
return UploadResult {
|
||||
storage_id: storage_id.clone(),
|
||||
success: false,
|
||||
error: Some("remote_file_path or total_size missing".to_string()),
|
||||
remote_file_path: None,
|
||||
total_size: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match self.ctx.api.backup_upload_status(
|
||||
self.ctx.edge_key.agent_id.clone(),
|
||||
generated_id.clone(),
|
||||
backup_storage_id,
|
||||
status,
|
||||
remote_path,
|
||||
total_size,
|
||||
backup_id,
|
||||
).await {
|
||||
Ok(_) => {
|
||||
upload_result
|
||||
}
|
||||
Err(err) => {
|
||||
error!(
|
||||
"backup_upload_status failed (generated_id={}, storage_id={}): {}",
|
||||
generated_id, storage_id, err
|
||||
);
|
||||
UploadResult {
|
||||
storage_id: storage_id.clone(),
|
||||
success: false,
|
||||
error: Some(err.to_string()),
|
||||
remote_file_path: None,
|
||||
total_size: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
error!("Skipping storage due to missing provider");
|
||||
UploadResult {
|
||||
storage_id: storage_id.clone(),
|
||||
success: false,
|
||||
error: Some(
|
||||
"Skipping storage due to missing provider".to_string(),
|
||||
),
|
||||
remote_file_path: None,
|
||||
total_size: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Unable to create the storage backup on remote server : {}",
|
||||
e
|
||||
);
|
||||
UploadResult {
|
||||
storage_id: storage_id.clone(),
|
||||
success: false,
|
||||
error: Some(
|
||||
"Unable to create the storage backup on remote server".to_string(),
|
||||
),
|
||||
remote_file_path: None,
|
||||
total_size: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let results: Vec<UploadResult> = join_all(upload_futures).await;
|
||||
info!("Upload results: {:#?}", results);
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
pub async fn send_result(
|
||||
&self,
|
||||
result: BackupResult,
|
||||
upload_results: Vec<UploadResult>,
|
||||
backup_id: &String,
|
||||
) -> Result<()> {
|
||||
let status = if upload_results.iter().any(|r| r.success) {
|
||||
"success"
|
||||
} else {
|
||||
"failed"
|
||||
};
|
||||
|
||||
let file_size = if status == "failed" {
|
||||
None
|
||||
} else {
|
||||
let mut sum = 0u64;
|
||||
let mut count = 0u64;
|
||||
|
||||
for size in upload_results.iter().filter_map(|r| r.total_size) {
|
||||
sum += size;
|
||||
count += 1;
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(sum / count)
|
||||
}
|
||||
};
|
||||
|
||||
match self
|
||||
.ctx
|
||||
.api
|
||||
.backup_update(self.ctx.edge_key.agent_id.clone(), backup_id, status, file_size, &result.generated_id)
|
||||
.await
|
||||
{
|
||||
Ok(_result) => Ok(()),
|
||||
Err(e) => {
|
||||
error!(
|
||||
"backup_update failed (generated_id={}, backup_id={}): {}",
|
||||
&result.generated_id, &backup_id, e
|
||||
);
|
||||
Err(e.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
use super::service::BackupService;
|
||||
use crate::utils::compress::compress_to_tar_gz_large;
|
||||
use std::path::PathBuf;
|
||||
use anyhow::Result;
|
||||
|
||||
impl BackupService {
|
||||
|
||||
pub async fn compress_backup(
|
||||
&self,
|
||||
backup_file: Option<PathBuf>,
|
||||
) -> Result<PathBuf> {
|
||||
|
||||
let file = backup_file
|
||||
.ok_or_else(|| anyhow::anyhow!("No backup file generated"))?;
|
||||
|
||||
let compression = compress_to_tar_gz_large(&file).await?;
|
||||
|
||||
Ok(compression.compressed_path)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use super::service::BackupService;
|
||||
use crate::services::config::DatabasesConfig;
|
||||
use crate::services::api::models::agent::status::DatabaseStorage;
|
||||
use crate::utils::common::BackupMethod;
|
||||
use tracing::error;
|
||||
|
||||
impl BackupService {
|
||||
|
||||
pub async fn dispatch(
|
||||
&self,
|
||||
generated_id: &String,
|
||||
config: &DatabasesConfig,
|
||||
method: BackupMethod,
|
||||
storages: &Vec<DatabaseStorage>,
|
||||
encrypt: bool,
|
||||
) {
|
||||
|
||||
let Some(cfg) = config
|
||||
.databases
|
||||
.iter()
|
||||
.find(|c| c.generated_id == generated_id.as_str())
|
||||
else {
|
||||
error!("Database config not found for {}", generated_id);
|
||||
return;
|
||||
};
|
||||
|
||||
let service = Self {
|
||||
ctx: self.ctx.clone(),
|
||||
};
|
||||
|
||||
let db_cfg = cfg.clone();
|
||||
let storages = storages.clone();
|
||||
let generated_id = generated_id.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = service
|
||||
.execute_backup(generated_id, db_cfg, method, storages, encrypt)
|
||||
.await
|
||||
{
|
||||
error!("Backup execution failed: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
use super::service::BackupService;
|
||||
use crate::services::config::DatabaseConfig;
|
||||
use crate::services::api::models::agent::status::DatabaseStorage;
|
||||
use crate::utils::common::BackupMethod;
|
||||
use crate::utils::locks::FileLock;
|
||||
|
||||
use tempfile::TempDir;
|
||||
use anyhow::Result;
|
||||
|
||||
impl BackupService {
|
||||
|
||||
pub async fn execute_backup(
|
||||
&self,
|
||||
generated_id: String,
|
||||
db_cfg: DatabaseConfig,
|
||||
method: BackupMethod,
|
||||
storages: Vec<DatabaseStorage>,
|
||||
encrypt: bool,
|
||||
) -> Result<()> {
|
||||
|
||||
if FileLock::is_locked(&generated_id).await? {
|
||||
anyhow::bail!("backup already running");
|
||||
}
|
||||
|
||||
let backup = self.create_backup_record(&generated_id, &method).await?;
|
||||
let backup_id = backup.backup.id;
|
||||
|
||||
let temp_dir = TempDir::new()?;
|
||||
let tmp_path = temp_dir.path();
|
||||
|
||||
let mut result = Self::run(db_cfg, tmp_path).await?;
|
||||
|
||||
if result.status == "failed" {
|
||||
self.send_result(result, vec![], &backup_id).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let compressed = self.compress_backup(result.backup_file.take()).await?;
|
||||
result.backup_file = Some(compressed);
|
||||
|
||||
let uploads = self
|
||||
.upload(result.clone(), method, storages, encrypt, &backup_id)
|
||||
.await?;
|
||||
|
||||
self.send_result(result, uploads, &backup_id).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
use super::service::BackupService;
|
||||
use crate::utils::common::BackupMethod;
|
||||
use anyhow::{Result, anyhow};
|
||||
use crate::services::api::models::agent::backup::BackupResponse;
|
||||
|
||||
impl BackupService {
|
||||
|
||||
pub async fn create_backup_record(
|
||||
&self,
|
||||
generated_id: &str,
|
||||
method: &BackupMethod,
|
||||
) -> Result<BackupResponse> {
|
||||
|
||||
let response = self
|
||||
.ctx
|
||||
.api
|
||||
.backup_create(
|
||||
method.to_string(),
|
||||
self.ctx.edge_key.agent_id.clone(),
|
||||
generated_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
response.ok_or_else(|| anyhow!("backup_create returned empty response"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
pub mod service;
|
||||
pub mod dispatcher;
|
||||
pub mod executor;
|
||||
pub mod compressor;
|
||||
pub mod uploader;
|
||||
pub mod result;
|
||||
pub mod models;
|
||||
pub mod helpers;
|
||||
pub mod runner;
|
||||
|
||||
pub use service::BackupService;
|
||||
@@ -0,0 +1,22 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::path::PathBuf;
|
||||
use crate::services::config::DbType;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BackupResult {
|
||||
pub generated_id: String,
|
||||
pub db_type: DbType,
|
||||
pub status: String,
|
||||
pub backup_file: Option<PathBuf>,
|
||||
pub code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UploadResult {
|
||||
pub storage_id: String,
|
||||
pub success: bool,
|
||||
pub error: Option<String>,
|
||||
pub remote_file_path: Option<String>,
|
||||
pub total_size: Option<u64>,
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
use super::models::{BackupResult, UploadResult};
|
||||
use super::service::BackupService;
|
||||
use crate::services::api::ApiError;
|
||||
use crate::services::api::models::agent::backup::BackupResponse;
|
||||
use anyhow::Result;
|
||||
use tracing::error;
|
||||
|
||||
impl BackupService {
|
||||
pub async fn send_result(
|
||||
&self,
|
||||
result: BackupResult,
|
||||
upload_results: Vec<UploadResult>,
|
||||
backup_id: &String,
|
||||
) -> Result<Option<BackupResponse>, ApiError> {
|
||||
let status = if upload_results.iter().any(|r| r.success) {
|
||||
"success"
|
||||
} else {
|
||||
"failed"
|
||||
};
|
||||
|
||||
let file_size = upload_results
|
||||
.iter()
|
||||
.filter_map(|r| r.total_size)
|
||||
.reduce(|a, b| a + b)
|
||||
.map(|sum| sum / upload_results.len() as u64);
|
||||
|
||||
self.ctx
|
||||
.api
|
||||
.backup_update(
|
||||
self.ctx.edge_key.agent_id.clone(),
|
||||
backup_id,
|
||||
status,
|
||||
file_size,
|
||||
&result.generated_id,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(
|
||||
"backup_update failed (generated_id={}, backup_id={}): {}",
|
||||
result.generated_id, backup_id, e
|
||||
);
|
||||
e.into()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
use super::models::BackupResult;
|
||||
use super::service::BackupService;
|
||||
|
||||
use crate::domain::factory::DatabaseFactory;
|
||||
use crate::services::config::DatabaseConfig;
|
||||
|
||||
use anyhow::Result;
|
||||
use std::path::Path;
|
||||
use tracing::{error, info};
|
||||
|
||||
impl BackupService {
|
||||
|
||||
pub async fn run(
|
||||
cfg: DatabaseConfig,
|
||||
tmp_path: &Path,
|
||||
) -> Result<BackupResult> {
|
||||
|
||||
let db = DatabaseFactory::create_for_backup(cfg.clone()).await;
|
||||
|
||||
let generated_id = cfg.generated_id.clone();
|
||||
let db_type = cfg.db_type.clone();
|
||||
|
||||
let reachable = match db.ping().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
error!("Ping failed: {}", e);
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
|
||||
info!("Reachable: {}", reachable);
|
||||
|
||||
if !reachable {
|
||||
return Ok(BackupResult {
|
||||
generated_id,
|
||||
db_type,
|
||||
status: "failed".into(),
|
||||
backup_file: None,
|
||||
code: None,
|
||||
});
|
||||
}
|
||||
|
||||
match db.backup(tmp_path, Some(false)).await {
|
||||
|
||||
Ok(file) => Ok(BackupResult {
|
||||
generated_id,
|
||||
db_type,
|
||||
status: "success".into(),
|
||||
backup_file: Some(file),
|
||||
code: None,
|
||||
}),
|
||||
|
||||
Err(e) if e.to_string() == "backup_already_in_progress" => Ok(BackupResult {
|
||||
generated_id,
|
||||
db_type,
|
||||
status: "failed".into(),
|
||||
backup_file: None,
|
||||
code: Some("backup_already_in_progress".into()),
|
||||
}),
|
||||
|
||||
Err(_) => Ok(BackupResult {
|
||||
generated_id,
|
||||
db_type,
|
||||
status: "failed".into(),
|
||||
backup_file: None,
|
||||
code: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
use std::sync::Arc;
|
||||
use crate::core::context::Context as CoreContext;
|
||||
|
||||
pub struct BackupService {
|
||||
pub ctx: Arc<CoreContext>,
|
||||
}
|
||||
|
||||
impl BackupService {
|
||||
pub fn new(ctx: Arc<CoreContext>) -> Self {
|
||||
Self { ctx }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
use super::service::BackupService;
|
||||
use super::models::{BackupResult, UploadResult};
|
||||
|
||||
use crate::services::storage;
|
||||
use crate::services::api::models::agent::status::DatabaseStorage;
|
||||
use crate::utils::common::BackupMethod;
|
||||
|
||||
use futures::future::join_all;
|
||||
use anyhow::{Result, bail};
|
||||
use tracing::{info, error};
|
||||
|
||||
impl BackupService {
|
||||
|
||||
pub async fn upload(
|
||||
&self,
|
||||
result: BackupResult,
|
||||
method: BackupMethod,
|
||||
storages: Vec<DatabaseStorage>,
|
||||
encrypt: bool,
|
||||
backup_id: &String,
|
||||
) -> Result<Vec<UploadResult>> {
|
||||
|
||||
if result.code.as_deref() == Some("backup_already_in_progress") {
|
||||
info!("Skipping send: backup already in progress");
|
||||
bail!("backup_already_in_progress");
|
||||
}
|
||||
|
||||
let ctx = self.ctx.clone();
|
||||
|
||||
let futures = storages.into_iter().map(|storage| {
|
||||
|
||||
let ctx_clone = ctx.clone();
|
||||
let result_clone = result.clone();
|
||||
let provider = storage::get_provider(&storage);
|
||||
|
||||
let storage_id = storage.id.clone();
|
||||
let generated_id = result_clone.generated_id.clone();
|
||||
|
||||
async move {
|
||||
|
||||
info!("Uploading storage -> {:?} for {:?}", storage.provider, storage_id);
|
||||
|
||||
/*
|
||||
INIT STEP
|
||||
*/
|
||||
let init = match ctx_clone.api
|
||||
.backup_upload_init(
|
||||
ctx_clone.edge_key.agent_id.clone(),
|
||||
generated_id.clone(),
|
||||
storage_id.clone(),
|
||||
backup_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
error!("backup_upload_init failed: {}", e);
|
||||
|
||||
return UploadResult {
|
||||
storage_id,
|
||||
success: false,
|
||||
error: Some("backup_upload_init failed".into()),
|
||||
remote_file_path: None,
|
||||
total_size: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let backup_storage_id = match init {
|
||||
Some(v) => v.backup_storage.id,
|
||||
None => {
|
||||
return UploadResult {
|
||||
storage_id,
|
||||
success: false,
|
||||
error: Some("backup_upload_init returned empty response".into()),
|
||||
remote_file_path: None,
|
||||
total_size: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
PROVIDER CHECK
|
||||
*/
|
||||
let Some(provider) = provider else {
|
||||
error!("Skipping storage due to missing provider");
|
||||
|
||||
return UploadResult {
|
||||
storage_id,
|
||||
success: false,
|
||||
error: Some("missing provider".into()),
|
||||
remote_file_path: None,
|
||||
total_size: None,
|
||||
};
|
||||
};
|
||||
|
||||
/*
|
||||
STORAGE UPLOAD
|
||||
*/
|
||||
let upload_result = provider
|
||||
.upload(
|
||||
ctx_clone.clone(),
|
||||
result_clone,
|
||||
method,
|
||||
&storage,
|
||||
Some(encrypt),
|
||||
)
|
||||
.await;
|
||||
|
||||
let status = if upload_result.success { "success" } else { "failed" };
|
||||
|
||||
if status != "success" {
|
||||
return upload_result;
|
||||
}
|
||||
|
||||
info!(
|
||||
"Storage {} uploaded to remote path {:?}",
|
||||
storage_id,
|
||||
upload_result.remote_file_path
|
||||
);
|
||||
|
||||
/*
|
||||
METADATA VALIDATION
|
||||
*/
|
||||
let (remote_path, total_size) = match (
|
||||
&upload_result.remote_file_path,
|
||||
upload_result.total_size,
|
||||
) {
|
||||
(Some(path), Some(size)) => (path.clone(), size),
|
||||
_ => {
|
||||
return UploadResult {
|
||||
storage_id,
|
||||
success: false,
|
||||
error: Some("remote_file_path or total_size missing".into()),
|
||||
remote_file_path: None,
|
||||
total_size: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
STATUS UPDATE
|
||||
*/
|
||||
match ctx_clone.api.backup_upload_status(
|
||||
ctx_clone.edge_key.agent_id.clone(),
|
||||
generated_id,
|
||||
backup_storage_id,
|
||||
status,
|
||||
remote_path,
|
||||
total_size,
|
||||
backup_id,
|
||||
).await {
|
||||
|
||||
Ok(_) => upload_result,
|
||||
|
||||
Err(err) => {
|
||||
error!(
|
||||
"backup_upload_status failed (storage_id={}): {}",
|
||||
storage_id,
|
||||
err
|
||||
);
|
||||
|
||||
UploadResult {
|
||||
storage_id,
|
||||
success: false,
|
||||
error: Some(err.to_string()),
|
||||
remote_file_path: None,
|
||||
total_size: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let results: Vec<UploadResult> = join_all(futures).await;
|
||||
|
||||
info!("Upload results: {:#?}", results);
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
@@ -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,246 +0,0 @@
|
||||
#![allow(dead_code)]
|
||||
#![warn(unused_assignments)]
|
||||
use crate::core::context::Context;
|
||||
use crate::domain::factory::DatabaseFactory;
|
||||
use crate::services::api::models::agent::status::DatabaseStatus;
|
||||
use crate::services::config::{DatabaseConfig, DatabasesConfig};
|
||||
use crate::utils::compress::decompress_large_tar_gz;
|
||||
use crate::utils::file::decrypt_file_stream_gcm;
|
||||
use anyhow::Result;
|
||||
use reqwest::{Client, Url};
|
||||
use serde::Serialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
use tracing::{error, info};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RestoreResult {
|
||||
#[serde(rename = "generatedId")]
|
||||
pub generated_id: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
pub struct RestoreService {
|
||||
ctx: Arc<Context>,
|
||||
}
|
||||
|
||||
impl RestoreService {
|
||||
pub fn new(ctx: Arc<Context>) -> Self {
|
||||
Self { ctx }
|
||||
}
|
||||
|
||||
pub async fn dispatch(&self, db: &DatabaseStatus, config: &DatabasesConfig) {
|
||||
if let Some(cfg) = config
|
||||
.databases
|
||||
.iter()
|
||||
.find(|c| c.generated_id == db.generated_id)
|
||||
{
|
||||
let db_cfg = cfg.clone();
|
||||
let ctx_clone = self.ctx.clone();
|
||||
let file_to_restore = db.data.restore.file.clone();
|
||||
if file_to_restore.is_none() {
|
||||
error!("restore file not found");
|
||||
return;
|
||||
}
|
||||
tokio::spawn(async move {
|
||||
match TempDir::new() {
|
||||
Ok(temp_dir) => {
|
||||
let tmp_path = temp_dir.path().to_path_buf();
|
||||
info!("Created temp directory {}", tmp_path.display());
|
||||
match RestoreService::run(
|
||||
&ctx_clone,
|
||||
db_cfg,
|
||||
&tmp_path,
|
||||
&file_to_restore.unwrap(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
let service = RestoreService { ctx: ctx_clone };
|
||||
service.send_result(result).await;
|
||||
}
|
||||
Err(e) => error!("Restoration error {}", e),
|
||||
}
|
||||
// TempDir is automatically deleted when dropped
|
||||
}
|
||||
Err(e) => error!("Failed to create temp dir: {}", e),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(
|
||||
ctx: &Arc<Context>,
|
||||
cfg: DatabaseConfig,
|
||||
tmp_path: &Path,
|
||||
file_url: &str,
|
||||
) -> Result<RestoreResult> {
|
||||
let generated_id = cfg.generated_id.clone();
|
||||
|
||||
info!("File url: {}", file_url);
|
||||
|
||||
let client = Client::new();
|
||||
let response = client.get(file_url).send().await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
error!("Backup download failed with status {}", response.status());
|
||||
return Ok(RestoreResult {
|
||||
generated_id,
|
||||
status: "failed".into(),
|
||||
});
|
||||
}
|
||||
|
||||
let filename_from_header = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_DISPOSITION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.split("filename=").nth(1))
|
||||
.map(|f| f.trim_matches('"').to_string());
|
||||
|
||||
let filename_from_url = Url::parse(file_url).ok().and_then(|u| {
|
||||
u.path_segments()?
|
||||
.last()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
|
||||
let filename = filename_from_header
|
||||
.or(filename_from_url)
|
||||
.unwrap_or_else(|| "downloaded_file".to_string());
|
||||
|
||||
info!("File name: {}", filename);
|
||||
|
||||
let bytes = response.bytes().await?;
|
||||
|
||||
let is_legacy_file = if filename.ends_with(".sql") {
|
||||
true
|
||||
} else if filename.ends_with(".dump") {
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let downloaded_file = tmp_path.join(&filename);
|
||||
tokio::fs::write(&downloaded_file, &bytes).await?;
|
||||
info!("Backup downloaded to {}", downloaded_file.display());
|
||||
|
||||
let backup_file_path: PathBuf = if !is_legacy_file {
|
||||
let encrypted = if filename.ends_with(".tar.gz") {
|
||||
false
|
||||
} else if filename.ends_with(".tar.gz.enc") {
|
||||
true
|
||||
} else {
|
||||
return Ok(RestoreResult {
|
||||
generated_id,
|
||||
status: "failed".into(),
|
||||
});
|
||||
};
|
||||
|
||||
info!("Encrypted: {}", encrypted);
|
||||
|
||||
let mut compressed_archive = downloaded_file.clone();
|
||||
|
||||
if encrypted {
|
||||
let new_name = downloaded_file
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.and_then(|n| n.strip_suffix(".enc"))
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid encrypted filename"))?;
|
||||
|
||||
let new_compressed_archive = tmp_path.join(new_name);
|
||||
|
||||
decrypt_file_stream_gcm(
|
||||
downloaded_file,
|
||||
new_compressed_archive.clone(),
|
||||
ctx.edge_key.master_key_b64.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to decrypt file: {}", e);
|
||||
e
|
||||
})?;
|
||||
|
||||
compressed_archive = new_compressed_archive;
|
||||
}
|
||||
|
||||
let decompressed_files =
|
||||
decompress_large_tar_gz(compressed_archive.as_path(), tmp_path).await?;
|
||||
|
||||
if decompressed_files.is_empty() {
|
||||
return Ok(RestoreResult {
|
||||
generated_id,
|
||||
status: "failed".into(),
|
||||
});
|
||||
}
|
||||
|
||||
if decompressed_files.len() == 1 {
|
||||
decompressed_files[0].clone()
|
||||
} else {
|
||||
compressed_archive
|
||||
}
|
||||
} else {
|
||||
downloaded_file.clone()
|
||||
};
|
||||
|
||||
let db_instance = DatabaseFactory::create_for_restore(cfg.clone(), &backup_file_path).await;
|
||||
let reachable = db_instance.ping().await.unwrap_or(false);
|
||||
info!("Reachable: {}", reachable);
|
||||
if !reachable {
|
||||
return Ok(RestoreResult {
|
||||
generated_id,
|
||||
status: "failed".into(),
|
||||
});
|
||||
}
|
||||
|
||||
match db_instance.restore(&backup_file_path).await {
|
||||
Ok(_) => Ok(RestoreResult {
|
||||
generated_id,
|
||||
status: "success".into(),
|
||||
}),
|
||||
Err(e) => {
|
||||
error!("Restore failed: {:?}", e);
|
||||
Ok(RestoreResult {
|
||||
generated_id,
|
||||
status: "failed".into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO : update with ctx api manager
|
||||
pub async fn send_result(&self, result: RestoreResult) {
|
||||
info!(
|
||||
"[RestoreService] DB: {} | Status: {}",
|
||||
result.generated_id, result.status,
|
||||
);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!(
|
||||
"{}/api/agent/{}/restore",
|
||||
self.ctx.edge_key.server_url, self.ctx.edge_key.agent_id
|
||||
);
|
||||
|
||||
let body = RestoreResult {
|
||||
generated_id: result.generated_id,
|
||||
status: result.status,
|
||||
};
|
||||
|
||||
match client.post(&url).json(&body).send().await {
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
info!("Restoration result sent successfully");
|
||||
} else {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
error!(
|
||||
"Restoration result failed, status: {}, body: {}",
|
||||
status, text
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to send restoration result: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
use super::service::RestoreService;
|
||||
|
||||
use crate::utils::compress::decompress_large_tar_gz;
|
||||
use crate::utils::file::decrypt_file_stream_gcm;
|
||||
|
||||
use anyhow::Result;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
impl RestoreService {
|
||||
|
||||
pub async fn prepare_archive(
|
||||
&self,
|
||||
downloaded_file: PathBuf,
|
||||
tmp_path: &Path,
|
||||
) -> Result<PathBuf> {
|
||||
|
||||
let filename = downloaded_file
|
||||
.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let is_legacy = filename.ends_with(".sql") || filename.ends_with(".dump");
|
||||
|
||||
if is_legacy {
|
||||
return Ok(downloaded_file);
|
||||
}
|
||||
|
||||
let encrypted = filename.ends_with(".tar.gz.enc");
|
||||
|
||||
let mut archive = downloaded_file.clone();
|
||||
|
||||
if encrypted {
|
||||
|
||||
let new_name = filename.strip_suffix(".enc").unwrap();
|
||||
|
||||
let decrypted = tmp_path.join(new_name);
|
||||
|
||||
decrypt_file_stream_gcm(
|
||||
downloaded_file,
|
||||
decrypted.clone(),
|
||||
self.ctx.edge_key.master_key_b64.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
archive = decrypted;
|
||||
}
|
||||
|
||||
let files = decompress_large_tar_gz(archive.as_path(), tmp_path).await?;
|
||||
|
||||
if files.is_empty() {
|
||||
anyhow::bail!("archive empty");
|
||||
}
|
||||
|
||||
if files.len() == 1 {
|
||||
Ok(files[0].clone())
|
||||
} else {
|
||||
Ok(archive)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
use super::service::RestoreService;
|
||||
use crate::services::config::DatabasesConfig;
|
||||
use crate::services::api::models::agent::status::DatabaseStatus;
|
||||
|
||||
use tracing::error;
|
||||
|
||||
impl RestoreService {
|
||||
|
||||
pub async fn dispatch(&self, db: &DatabaseStatus, config: &DatabasesConfig) {
|
||||
|
||||
let Some(cfg) = config
|
||||
.databases
|
||||
.iter()
|
||||
.find(|c| c.generated_id == db.generated_id)
|
||||
else {
|
||||
error!("Database config not found");
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(file_to_restore) = db.data.restore.file.clone() else {
|
||||
error!("restore file not found");
|
||||
return;
|
||||
};
|
||||
|
||||
let service = Self {
|
||||
ctx: self.ctx.clone(),
|
||||
};
|
||||
|
||||
let db_cfg = cfg.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
|
||||
if let Err(e) = service
|
||||
.execute_restore(db_cfg, file_to_restore)
|
||||
.await
|
||||
{
|
||||
error!("Restore failed: {}", e);
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
use super::service::RestoreService;
|
||||
|
||||
use reqwest::{Client, Url};
|
||||
use anyhow::Result;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::info;
|
||||
|
||||
impl RestoreService {
|
||||
|
||||
pub async fn download_backup(
|
||||
&self,
|
||||
file_url: &str,
|
||||
tmp_path: &Path,
|
||||
) -> Result<PathBuf> {
|
||||
|
||||
let client = Client::new();
|
||||
|
||||
let response = client.get(file_url).send().await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
anyhow::bail!("download failed");
|
||||
}
|
||||
|
||||
let filename_from_header = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_DISPOSITION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.split("filename=").nth(1))
|
||||
.map(|f| f.trim_matches('"').to_string());
|
||||
|
||||
|
||||
let filename_from_url = Url::parse(file_url).ok().and_then(|u| {
|
||||
u.path_segments()?
|
||||
.last()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
|
||||
let filename = filename_from_header
|
||||
.or(filename_from_url)
|
||||
.unwrap_or_else(|| "downloaded_file".to_string());
|
||||
|
||||
let path = tmp_path.join(&filename);
|
||||
|
||||
let bytes = response.bytes().await?;
|
||||
|
||||
tokio::fs::write(&path, &bytes).await?;
|
||||
|
||||
info!("Backup downloaded to {}", path.display());
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
use super::service::RestoreService;
|
||||
use crate::services::config::DatabaseConfig;
|
||||
use tempfile::TempDir;
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
|
||||
impl RestoreService {
|
||||
|
||||
pub async fn execute_restore(
|
||||
&self,
|
||||
cfg: DatabaseConfig,
|
||||
file_url: String,
|
||||
) -> Result<()> {
|
||||
|
||||
let temp_dir = TempDir::new()?;
|
||||
let tmp_path = temp_dir.path();
|
||||
|
||||
info!("Created temp directory {}", tmp_path.display());
|
||||
|
||||
let downloaded = self.download_backup(&file_url, tmp_path).await?;
|
||||
|
||||
let backup_file = self.prepare_archive(downloaded, tmp_path).await?;
|
||||
|
||||
let result = self.run_restore(cfg, backup_file).await?;
|
||||
|
||||
self.send_result(result).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
pub mod service;
|
||||
pub mod dispatcher;
|
||||
pub mod executor;
|
||||
pub mod downloader;
|
||||
pub mod archive;
|
||||
pub mod runner;
|
||||
pub mod result;
|
||||
pub mod models;
|
||||
|
||||
pub use service::RestoreService;
|
||||
@@ -0,0 +1,8 @@
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RestoreResult {
|
||||
#[serde(rename = "generatedId")]
|
||||
pub generated_id: String,
|
||||
pub status: String,
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
use super::service::RestoreService;
|
||||
use super::models::RestoreResult;
|
||||
|
||||
use tracing::{info, error};
|
||||
|
||||
impl RestoreService {
|
||||
pub async fn send_result(&self, result: RestoreResult) {
|
||||
|
||||
info!(
|
||||
"[RestoreService] DB: {} | Status: {}",
|
||||
result.generated_id, result.status
|
||||
);
|
||||
|
||||
match self.ctx
|
||||
.api
|
||||
.restore_result(
|
||||
self.ctx.edge_key.agent_id.clone(),
|
||||
&result.generated_id,
|
||||
&result.status,
|
||||
)
|
||||
.await {
|
||||
Ok(_) => {
|
||||
info!("Restoration result sent successfully");
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to send restoration result: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
use super::service::RestoreService;
|
||||
use super::models::RestoreResult;
|
||||
|
||||
use crate::domain::factory::DatabaseFactory;
|
||||
use crate::services::config::DatabaseConfig;
|
||||
|
||||
use anyhow::Result;
|
||||
use std::path::PathBuf;
|
||||
use tracing::{info, error};
|
||||
|
||||
impl RestoreService {
|
||||
|
||||
pub async fn run_restore(
|
||||
&self,
|
||||
cfg: DatabaseConfig,
|
||||
backup_file: PathBuf,
|
||||
) -> Result<RestoreResult> {
|
||||
|
||||
let generated_id = cfg.generated_id.clone();
|
||||
|
||||
let db = DatabaseFactory::create_for_restore(cfg.clone(), &backup_file).await;
|
||||
|
||||
let reachable = db.ping().await.unwrap_or(false);
|
||||
|
||||
info!("Reachable: {}", reachable);
|
||||
|
||||
if !reachable {
|
||||
return Ok(RestoreResult {
|
||||
generated_id,
|
||||
status: "failed".into(),
|
||||
});
|
||||
}
|
||||
|
||||
match db.restore(&backup_file, Some(false)).await {
|
||||
|
||||
Ok(_) => Ok(RestoreResult {
|
||||
generated_id,
|
||||
status: "success".into(),
|
||||
}),
|
||||
|
||||
Err(e) => {
|
||||
error!("Restore failed: {:?}", e);
|
||||
|
||||
Ok(RestoreResult {
|
||||
generated_id,
|
||||
status: "failed".into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
use std::sync::Arc;
|
||||
use crate::core::context::Context;
|
||||
|
||||
pub struct RestoreService {
|
||||
pub ctx: Arc<Context>,
|
||||
}
|
||||
|
||||
impl RestoreService {
|
||||
pub fn new(ctx: Arc<Context>) -> Self {
|
||||
Self { ctx }
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
pub mod providers;
|
||||
|
||||
use crate::core::context::Context;
|
||||
use crate::services::backup::{BackupResult, UploadResult};
|
||||
use crate::utils::common::BackupMethod;
|
||||
use async_trait::async_trait;
|
||||
use providers::local;
|
||||
@@ -10,6 +9,7 @@ use providers::google_drive;
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info};
|
||||
use crate::services::api::models::agent::status::DatabaseStorage;
|
||||
use crate::services::backup::models::{BackupResult, UploadResult};
|
||||
|
||||
#[async_trait]
|
||||
pub trait StorageProvider: Send + Sync {
|
||||
|
||||
@@ -3,7 +3,6 @@ mod models;
|
||||
|
||||
use crate::core::context::Context;
|
||||
use crate::services::api::models::agent::status::DatabaseStorage;
|
||||
use crate::services::backup::{BackupResult, UploadResult};
|
||||
use crate::services::storage::StorageProvider;
|
||||
use crate::utils::common::BackupMethod;
|
||||
use crate::utils::file::{full_file_name, full_file_path};
|
||||
@@ -12,6 +11,7 @@ use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
use tokio::fs;
|
||||
use tracing::{error, info};
|
||||
use crate::services::backup::models::{BackupResult, UploadResult};
|
||||
use crate::services::storage::providers::google_drive::helpers::{upload_stream_to_google_drive};
|
||||
use crate::services::storage::providers::google_drive::models::GoogleDriveProviderConfig;
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use crate::core::context::Context;
|
||||
use crate::services::api::models::agent::status::DatabaseStorage;
|
||||
use crate::services::backup::{BackupResult, UploadResult};
|
||||
use crate::services::storage::StorageProvider;
|
||||
use crate::utils::common::BackupMethod;
|
||||
use crate::utils::file::{full_file_name, full_file_path};
|
||||
@@ -11,6 +10,7 @@ use reqwest::header::{HeaderMap, HeaderValue};
|
||||
use std::sync::Arc;
|
||||
use tokio::fs;
|
||||
use tracing::error;
|
||||
use crate::services::backup::models::{BackupResult, UploadResult};
|
||||
|
||||
pub struct LocalProvider;
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ mod models;
|
||||
|
||||
use crate::core::context::Context;
|
||||
use crate::services::api::models::agent::status::DatabaseStorage;
|
||||
use crate::services::backup::{BackupResult, UploadResult};
|
||||
use crate::services::storage::StorageProvider;
|
||||
use crate::services::storage::providers::s3::models::S3ProviderConfig;
|
||||
use crate::utils::common::BackupMethod;
|
||||
@@ -17,8 +16,12 @@ 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};
|
||||
|
||||
pub struct S3Provider {}
|
||||
|
||||
@@ -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
@@ -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()),
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
mod postgres;
|
||||
mod redis;
|
||||
mod valkey;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 }
|
||||
])));
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mod normalize_cron_tests;
|
||||
mod common_tests;
|
||||
mod compress_tests;
|
||||
mod deserializer;
|
||||
mod edge_key_tests;
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user