mirror of
https://github.com/Portabase/agent.git
synced 2026-09-11 14:00:14 +00:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f95f0aa73a | |||
| 2177dfd44b | |||
| 0a53eec184 | |||
| f7f5f7e141 | |||
| 2170f96a72 | |||
| 298d46ba81 | |||
| 6537e9df53 | |||
| b8d869d5a6 | |||
| 90941ea67d | |||
| 046d593e2b | |||
| cf9a59a138 | |||
| 2464f6dfb0 | |||
| 069067ca55 | |||
| 04b654d219 | |||
| d83511cb64 | |||
| ca294e968c | |||
| 1be88ffdb9 | |||
| 9d393a96a4 | |||
| 044bf80633 | |||
| 0a6eb6db22 | |||
| b26ff81889 | |||
| 4e2f29f4ca | |||
| d1c8df4cac | |||
| 30f83bafcf | |||
| de106c835e | |||
| 23d6822ddc | |||
| fe1d74945f | |||
| 424a646385 | |||
| 0ef4bba5d7 | |||
| 6548140eaf | |||
| c9725c381e | |||
| 03695b1897 | |||
| 80b1c0dac0 | |||
| 5ededb3764 | |||
| 54cfd673e4 | |||
| 39dbeb5d30 | |||
| 380cea79fb | |||
| 65e4538c2c | |||
| 86a706bca5 | |||
| e8bda60f16 | |||
| 034912b176 | |||
| 4fd50ccda4 | |||
| 84c6b765c8 | |||
| a93054518a | |||
| c186d57105 | |||
| 37b4c1fe6d | |||
| 92a3324ca2 |
+79
-17
@@ -27,15 +27,80 @@ permissions:
|
|||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish:
|
build:
|
||||||
name: Build and push to GHCR
|
name: Build ${{ matrix.platform }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ${{ matrix.platform == 'linux/amd64' && 'ubuntu-latest' || 'ubuntu-24.04-arm' }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
platform: [ linux/amd64, linux/arm64 ]
|
||||||
steps:
|
steps:
|
||||||
|
- name: Prepare vars
|
||||||
|
id: prep
|
||||||
|
run: |
|
||||||
|
ARCH="${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}"
|
||||||
|
echo "arch=$ARCH" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "image=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/agent" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.ref }}
|
ref: ${{ inputs.ref }}
|
||||||
fetch-depth: 0
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push by digest
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ${{ inputs.dockerfile }}
|
||||||
|
platforms: ${{ matrix.platform }}
|
||||||
|
target: ${{ inputs.target }}
|
||||||
|
provenance: false
|
||||||
|
outputs: type=image,name=${{ steps.prep.outputs.image }},push-by-digest=true,name-canonical=true,push=true
|
||||||
|
cache-from: type=gha,scope=ghcr-${{ steps.prep.outputs.arch }}
|
||||||
|
cache-to: type=gha,mode=max,scope=ghcr-${{ steps.prep.outputs.arch }},ignore-error=true
|
||||||
|
|
||||||
|
- name: Export digest
|
||||||
|
env:
|
||||||
|
DIGEST: ${{ steps.build.outputs.digest }}
|
||||||
|
run: |
|
||||||
|
mkdir -p /tmp/digests
|
||||||
|
touch "/tmp/digests/${DIGEST#sha256:}"
|
||||||
|
|
||||||
|
- name: Upload digest
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: digest-${{ steps.prep.outputs.arch }}
|
||||||
|
path: /tmp/digests/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
merge:
|
||||||
|
name: Create multi-arch manifest
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build
|
||||||
|
steps:
|
||||||
|
- name: Prepare vars
|
||||||
|
id: prep
|
||||||
|
run: echo "image=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/agent" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Download digests
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: /tmp/digests
|
||||||
|
pattern: digest-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
@@ -51,22 +116,19 @@ jobs:
|
|||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: ghcr.io/${{ github.repository_owner }}/agent
|
images: ${{ steps.prep.outputs.image }}
|
||||||
tags: |
|
tags: |
|
||||||
type=semver,pattern={{version}},value=${{ inputs.version }}
|
type=semver,pattern={{version}},value=${{ inputs.version }}
|
||||||
type=semver,pattern={{major}}.{{minor}},value=${{ inputs.version }}
|
type=semver,pattern={{major}}.{{minor}},value=${{ inputs.version }}
|
||||||
type=semver,pattern={{major}},value=${{ inputs.version }}
|
type=semver,pattern={{major}},value=${{ inputs.version }}
|
||||||
type=raw,value=latest,enable=${{ inputs.add_latest }}
|
type=raw,value=latest,enable=${{ inputs.add_latest }}
|
||||||
|
|
||||||
- name: Build and push image
|
- name: Create and push manifest list
|
||||||
uses: docker/build-push-action@v6
|
working-directory: /tmp/digests
|
||||||
with:
|
run: |
|
||||||
context: .
|
docker buildx imagetools create \
|
||||||
file: ${{ inputs.dockerfile }}
|
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||||
platforms: linux/amd64,linux/arm64
|
$(printf '${{ steps.prep.outputs.image }}@sha256:%s ' *)
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
- name: Inspect
|
||||||
target: ${{ inputs.target }}
|
run: docker buildx imagetools inspect ${{ steps.prep.outputs.image }}:${{ inputs.version }}
|
||||||
provenance: false
|
|
||||||
cache-from: type=gha,scope=ghcr-build
|
|
||||||
cache-to: type=gha,mode=max,scope=ghcr-build,ignore-error=true
|
|
||||||
|
|||||||
@@ -118,11 +118,24 @@ jobs:
|
|||||||
secrets:
|
secrets:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
build-windows:
|
||||||
|
needs: create-release
|
||||||
|
if: ${{ needs.create-release.result == 'success' }}
|
||||||
|
uses: ./.github/workflows/windows-release.yml
|
||||||
|
with:
|
||||||
|
version: ${{ needs.create-release.outputs.version }}
|
||||||
|
ref: ${{ needs.create-release.outputs.version }}
|
||||||
|
draft_tag: ${{ needs.create-release.outputs.draft_tag }}
|
||||||
|
secrets:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
finalize-release:
|
finalize-release:
|
||||||
needs:
|
needs:
|
||||||
- create-release
|
- create-release
|
||||||
- publish-docker
|
- publish-docker
|
||||||
|
- publish-docker-ghcr
|
||||||
- publish-helm
|
- publish-helm
|
||||||
|
- build-windows
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
release_tag: ${{ steps.publish_release_step.outputs.release_tag }}
|
release_tag: ${{ steps.publish_release_step.outputs.release_tag }}
|
||||||
|
|||||||
@@ -1,14 +1,30 @@
|
|||||||
name: Build Windows release
|
name: Build Windows release
|
||||||
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: 'Release version (git tag), e.g. 1.18.4'
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
ref:
|
||||||
|
description: 'Git ref to check out and build'
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
draft_tag:
|
||||||
|
description: 'Draft GitHub release tag to attach the asset to (e.g. untagged-xxxx). Empty = skip upload.'
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
secrets:
|
||||||
|
GH_TOKEN:
|
||||||
|
required: false
|
||||||
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
inputs:
|
||||||
tags:
|
ref:
|
||||||
- '[0-9]+.[0-9]+.[0-9]+'
|
description: 'Git ref to check out and build'
|
||||||
branches:
|
type: string
|
||||||
- main
|
required: false
|
||||||
- master
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-windows:
|
build-windows:
|
||||||
@@ -17,80 +33,68 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
- name: Set up Rust toolchain (MSVC)
|
- name: Set up Rust toolchain (MSVC)
|
||||||
uses: actions-rs/toolchain@v1
|
uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
toolchain: stable-x86_64-pc-windows-msvc
|
targets: x86_64-pc-windows-msvc
|
||||||
profile: minimal
|
|
||||||
override: true
|
|
||||||
|
|
||||||
- name: Install vcpkg and OpenSSL (x64)
|
- name: Cache cargo build
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
|
- name: Cache vcpkg installed packages
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: C:\vcpkg\installed
|
||||||
|
key: vcpkg-openssl-x64-windows-v1
|
||||||
|
|
||||||
|
- name: Install OpenSSL (x64) via vcpkg
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
# Install vcpkg and the prebuilt OpenSSL package
|
# windows-latest ships vcpkg preinstalled; the install is a no-op when the
|
||||||
git clone https://github.com/microsoft/vcpkg C:\vcpkg
|
# package is restored from cache.
|
||||||
C:\vcpkg\bootstrap-vcpkg.bat
|
& "$env:VCPKG_INSTALLATION_ROOT\vcpkg.exe" install openssl:x64-windows
|
||||||
C:\vcpkg\vcpkg install openssl:x64-windows
|
|
||||||
# Export variables for subsequent steps
|
|
||||||
'VCPKG_ROOT=C:\vcpkg' | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
'VCPKG_ROOT=C:\vcpkg' | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||||
'OPENSSL_DIR=C:\vcpkg\installed\x64-windows' | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
'OPENSSL_DIR=C:\vcpkg\installed\x64-windows' | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||||
|
|
||||||
- name: Build (cargo release)
|
- name: Build (cargo release)
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
env:
|
run: cargo build --release --bin app
|
||||||
# Cargo / openssl-sys will pick up OPENSSL_DIR from the environment
|
|
||||||
OPENSSL_DIR: ${{ env.OPENSSL_DIR }}
|
|
||||||
run: |
|
|
||||||
# Ensure the environment variable is present for this step
|
|
||||||
if (-Not $env:OPENSSL_DIR) { Write-Host "OPENSSL_DIR not set, printing env for debugging"; Get-ChildItem Env: | ForEach-Object { Write-Host $_ } }
|
|
||||||
# Build the declared bin target explicitly (Cargo.toml [[bin]] name = "app")
|
|
||||||
cargo build --release --bin app
|
|
||||||
|
|
||||||
- name: Prepare artifact zip
|
- name: Prepare artifact zip
|
||||||
id: prepare_artifact
|
id: prepare_artifact
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
env:
|
env:
|
||||||
RELEASE_TAG: ${{ github.ref_name }}
|
RELEASE_VERSION: ${{ inputs.version }}
|
||||||
run: |
|
run: |
|
||||||
$tag = $env:RELEASE_TAG
|
$tag = $env:RELEASE_VERSION
|
||||||
if (-not $tag) { $tag = $env:GITHUB_SHA }
|
if (-not $tag) { $tag = $env:GITHUB_SHA }
|
||||||
|
|
||||||
# Package the declared bin target deterministically (Cargo.toml [[bin]] name = "app")
|
|
||||||
$exe = "target\release\app.exe"
|
$exe = "target\release\app.exe"
|
||||||
if (-not (Test-Path $exe)) { Write-Error "Built binary $exe not found in target/release"; exit 1 }
|
if (-not (Test-Path $exe)) { Write-Error "Built binary $exe not found in target/release"; exit 1 }
|
||||||
|
|
||||||
$outDir = "artifact"
|
$outDir = "artifact"
|
||||||
New-Item -ItemType Directory -Path $outDir -Force | Out-Null
|
New-Item -ItemType Directory -Path $outDir -Force | Out-Null
|
||||||
# Ship under the package name, not the internal bin name "app"
|
# Ship under the package name, not the internal bin name "app".
|
||||||
Copy-Item -Path $exe -Destination "$outDir\portabase-agent.exe"
|
Copy-Item -Path $exe -Destination "$outDir\portabase-agent.exe"
|
||||||
|
|
||||||
$zipName = "windows-release-$tag.zip"
|
$zipName = "windows-release-$tag.zip"
|
||||||
if (Test-Path $zipName) { Remove-Item $zipName }
|
if (Test-Path $zipName) { Remove-Item $zipName }
|
||||||
Compress-Archive -Path "$outDir\*" -DestinationPath $zipName -Force
|
Compress-Archive -Path "$outDir\*" -DestinationPath $zipName -Force
|
||||||
Write-Host "ZIP=$zipName"
|
"zip=$zipName" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
|
||||||
Write-Output "zip=$zipName" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
|
|
||||||
|
|
||||||
- name: Upload build artifact
|
- name: Upload build artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: windows-release
|
name: windows-release
|
||||||
path: windows-release-*.zip
|
path: ${{ steps.prepare_artifact.outputs.zip }}
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Attach asset to draft release
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: ${{ inputs.draft_tag != '' }}
|
||||||
id: create_release
|
shell: pwsh
|
||||||
uses: softprops/action-gh-release@v1
|
|
||||||
with:
|
|
||||||
tag_name: ${{ github.ref_name }}
|
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||||
|
run: |
|
||||||
- name: Upload release asset
|
gh release upload "${{ inputs.draft_tag }}" "${{ steps.prepare_artifact.outputs.zip }}" --clobber
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
uses: actions/upload-release-asset@v1
|
|
||||||
with:
|
|
||||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
|
||||||
asset_path: windows-release-${{ github.ref_name }}.zip
|
|
||||||
asset_name: windows-release-${{ github.ref_name }}.zip
|
|
||||||
asset_content_type: application/zip
|
|
||||||
|
|||||||
+1
-1
@@ -27,5 +27,5 @@ keywords:
|
|||||||
- self-hosted
|
- self-hosted
|
||||||
- portabase
|
- portabase
|
||||||
license: Apache-2.0
|
license: Apache-2.0
|
||||||
version: 1.17.1
|
version: 1.19.2
|
||||||
date-released: '2026-02-24'
|
date-released: '2026-02-24'
|
||||||
|
|||||||
Generated
+2
-1
@@ -3503,7 +3503,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "portabase-agent"
|
name = "portabase-agent"
|
||||||
version = "1.17.1"
|
version = "1.19.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
@@ -3535,6 +3535,7 @@ dependencies = [
|
|||||||
"oauth2",
|
"oauth2",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"openssl",
|
"openssl",
|
||||||
|
"percent-encoding",
|
||||||
"postgres",
|
"postgres",
|
||||||
"rand 0.9.2",
|
"rand 0.9.2",
|
||||||
"redis",
|
"redis",
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "portabase-agent"
|
name = "portabase-agent"
|
||||||
version = "1.17.1"
|
version = "1.19.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -57,6 +57,7 @@ testcontainers = "0.27.1"
|
|||||||
testcontainers-modules = { version = "0.15.0", features = ["postgres", "redis", "valkey", "mysql", "mariadb", "mongo"] }
|
testcontainers-modules = { version = "0.15.0", features = ["postgres", "redis", "valkey", "mysql", "mariadb", "mongo"] }
|
||||||
postgres = "0.19.12"
|
postgres = "0.19.12"
|
||||||
url = "2.5.8"
|
url = "2.5.8"
|
||||||
|
percent-encoding = "2.3.2"
|
||||||
bollard = "0.20.0"
|
bollard = "0.20.0"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
+6
-47
@@ -1,13 +1,14 @@
|
|||||||
services:
|
services:
|
||||||
rust-app:
|
rust-app:
|
||||||
# build:
|
build:
|
||||||
# context: .
|
context: .
|
||||||
# dockerfile: docker/Dockerfile
|
dockerfile: docker/Dockerfile
|
||||||
# target: prod
|
target: prod
|
||||||
image: portabase/agent:latest
|
# image: portabase/agent:latest
|
||||||
container_name: rust-prod
|
container_name: rust-prod
|
||||||
volumes:
|
volumes:
|
||||||
- ./databases.json:/config/config.json
|
- ./databases.json:/config/config.json
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
environment:
|
environment:
|
||||||
LOG: info
|
LOG: info
|
||||||
TZ: "Europe/Paris"
|
TZ: "Europe/Paris"
|
||||||
@@ -18,48 +19,6 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- portabase
|
- 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
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
mongodb-data:
|
|
||||||
mongodb-data-auth:
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
portabase:
|
portabase:
|
||||||
name: portabase_network
|
name: portabase_network
|
||||||
|
|||||||
+2
-2
@@ -9,7 +9,7 @@ services:
|
|||||||
- .:/app
|
- .:/app
|
||||||
- cargo-registry:/usr/local/cargo/registry
|
- cargo-registry:/usr/local/cargo/registry
|
||||||
- cargo-git:/usr/local/cargo/git
|
- cargo-git:/usr/local/cargo/git
|
||||||
- ./databases.json:/config/config.json
|
# - ./databases.json:/config/config.json
|
||||||
#- ./databases.toml:/config/config.toml
|
#- ./databases.toml:/config/config.toml
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
# - cargo-target:/app/target
|
# - cargo-target:/app/target
|
||||||
@@ -21,7 +21,7 @@ services:
|
|||||||
LOG: debug
|
LOG: debug
|
||||||
TZ: "Europe/Paris"
|
TZ: "Europe/Paris"
|
||||||
# TMPDIR: /scratch
|
# TMPDIR: /scratch
|
||||||
EDGE_KEY: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiNmM4NWE3ODQtODRkMi00YzUyLTgzYmUtZTc2MDZkZjg2YjM5IiwibWFzdGVyS2V5QjY0IjoiMUh0djdtWCtYVkJxL0IzUEV2WDlZZjlQeUdVZW5oRHlXemo5THRqNW90WT0ifQ=="
|
EDGE_KEY: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiZjlkZjhiNWYtM2I0MC00NWM3LWI3N2UtYzY4NzQ1YmU2NjMwIiwibWFzdGVyS2V5QjY0IjoiQlhWM1hvbEM2NTZTVjdkTmdjV1BHUWxrKytycExJNmxHRGk3Q1BCNWllbz0ifQ=="
|
||||||
#CHUNK_SIZE_MB: "1"
|
#CHUNK_SIZE_MB: "1"
|
||||||
#POOLING: 1
|
#POOLING: 1
|
||||||
#DATABASES_CONFIG_FILE: "config.toml"
|
#DATABASES_CONFIG_FILE: "config.toml"
|
||||||
|
|||||||
@@ -142,6 +142,8 @@ RUN curl -sSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh \
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN mkdir -p /config
|
||||||
|
|
||||||
COPY --from=builder /app/target/release/app /usr/local/bin/app
|
COPY --from=builder /app/target/release/app /usr/local/bin/app
|
||||||
COPY --from=builder /app/version.env /app/version.env
|
COPY --from=builder /app/version.env /app/version.env
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
|||||||
@@ -45,6 +45,14 @@ seed-firebird:
|
|||||||
echo "SELECT RDB\$RELATION_NAME FROM RDB\$RELATIONS WHERE RDB\$SYSTEM_FLAG = 0 AND RDB\$VIEW_BLR IS NULL;" \
|
echo "SELECT RDB\$RELATION_NAME FROM RDB\$RELATIONS WHERE RDB\$SYSTEM_FLAG = 0 AND RDB\$VIEW_BLR IS NULL;" \
|
||||||
| docker exec -i db-firebird isql -user alice -password fake_password /var/lib/firebird/data/mirror.fdb
|
| docker exec -i db-firebird isql -user alice -password fake_password /var/lib/firebird/data/mirror.fdb
|
||||||
|
|
||||||
|
seed-firebird-large:
|
||||||
|
echo "Seeding Firebird..."
|
||||||
|
docker exec -i db-firebird isql -user alice -password fake_password /var/lib/firebird/data/mirror.fdb < ./scripts/firebird/seed-large.sql
|
||||||
|
|
||||||
|
echo "Verifying Firebird tables..."
|
||||||
|
echo "SELECT RDB\$RELATION_NAME FROM RDB\$RELATIONS WHERE RDB\$SYSTEM_FLAG = 0 AND RDB\$VIEW_BLR IS NULL;" \
|
||||||
|
| docker exec -i db-firebird isql -user alice -password fake_password /var/lib/firebird/data/mirror.fdb
|
||||||
|
|
||||||
seed-mssql:
|
seed-mssql:
|
||||||
echo "Seeding MSSQL..."
|
echo "Seeding MSSQL..."
|
||||||
docker exec -i rust-dev sqlcmd -S "db-mssql,1433" -U sa -P "$MSSQL_SA_PASSWORD" -N disable -i /app/scripts/mssql/seed.sql
|
docker exec -i rust-dev sqlcmd -S "db-mssql,1433" -U sa -P "$MSSQL_SA_PASSWORD" -N disable -i /app/scripts/mssql/seed.sql
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
SET SQL DIALECT 3;
|
||||||
|
SET BAIL ON;
|
||||||
|
SET AUTODDL OFF;
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
email VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(255),
|
||||||
|
payload BLOB SUB_TYPE TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
INSERT INTO users (email, name, payload)
|
||||||
|
VALUES ('alice@example.com', 'Alice', 'Alice seed data');
|
||||||
|
|
||||||
|
INSERT INTO users (email, name, payload)
|
||||||
|
VALUES ('bob@example.com', 'Bob', 'Bob seed data');
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Each procedure call generates approximately 128 MiB:
|
||||||
|
*
|
||||||
|
* 128 rows
|
||||||
|
* × 128 chunks per row
|
||||||
|
* × 8191 bytes per chunk
|
||||||
|
* = approximately 128 MiB
|
||||||
|
*
|
||||||
|
* 40 calls = approximately 5 GiB.
|
||||||
|
*/
|
||||||
|
|
||||||
|
SET TERM ^;
|
||||||
|
|
||||||
|
CREATE PROCEDURE seed_users_batch (
|
||||||
|
p_rows INTEGER,
|
||||||
|
p_chunks_per_row INTEGER
|
||||||
|
)
|
||||||
|
AS
|
||||||
|
DECLARE VARIABLE v_row_index INTEGER;
|
||||||
|
DECLARE VARIABLE v_chunk_index INTEGER;
|
||||||
|
DECLARE VARIABLE v_uuid VARCHAR(36);
|
||||||
|
DECLARE VARIABLE v_chunk VARCHAR(8191);
|
||||||
|
DECLARE VARIABLE v_payload BLOB SUB_TYPE TEXT;
|
||||||
|
BEGIN
|
||||||
|
v_row_index = 0;
|
||||||
|
|
||||||
|
WHILE (v_row_index < p_rows) DO
|
||||||
|
BEGIN
|
||||||
|
v_payload = NULL;
|
||||||
|
v_chunk_index = 0;
|
||||||
|
|
||||||
|
WHILE (v_chunk_index < p_chunks_per_row) DO
|
||||||
|
BEGIN
|
||||||
|
/*
|
||||||
|
* Generate a different chunk to avoid producing a completely
|
||||||
|
* uniform BLOB.
|
||||||
|
*/
|
||||||
|
v_chunk = RPAD(
|
||||||
|
UUID_TO_CHAR(GEN_UUID()),
|
||||||
|
8191,
|
||||||
|
UUID_TO_CHAR(GEN_UUID())
|
||||||
|
);
|
||||||
|
|
||||||
|
v_payload = BLOB_APPEND(v_payload, v_chunk);
|
||||||
|
v_chunk_index = v_chunk_index + 1;
|
||||||
|
END
|
||||||
|
|
||||||
|
v_uuid = UUID_TO_CHAR(GEN_UUID());
|
||||||
|
|
||||||
|
INSERT INTO users (
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
payload
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
:v_uuid || '@example.test',
|
||||||
|
'Seed User ' || :v_uuid,
|
||||||
|
:v_payload
|
||||||
|
);
|
||||||
|
|
||||||
|
v_row_index = v_row_index + 1;
|
||||||
|
END
|
||||||
|
END^
|
||||||
|
|
||||||
|
SET TERM ;^
|
||||||
|
|
||||||
|
/* Batch 01 — approximately 128 MiB */
|
||||||
|
EXECUTE PROCEDURE seed_users_batch(128, 128);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/* Batch 02 */
|
||||||
|
EXECUTE PROCEDURE seed_users_batch(128, 128);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/* Batch 03 */
|
||||||
|
EXECUTE PROCEDURE seed_users_batch(128, 128);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/* Batch 04 */
|
||||||
|
EXECUTE PROCEDURE seed_users_batch(128, 128);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/* Batch 05 */
|
||||||
|
EXECUTE PROCEDURE seed_users_batch(128, 128);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/* Batch 06 */
|
||||||
|
EXECUTE PROCEDURE seed_users_batch(128, 128);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/* Batch 07 */
|
||||||
|
EXECUTE PROCEDURE seed_users_batch(128, 128);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/* Batch 08 — approximately 1 GiB total */
|
||||||
|
EXECUTE PROCEDURE seed_users_batch(128, 128);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/* Batch 09 */
|
||||||
|
EXECUTE PROCEDURE seed_users_batch(128, 128);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/* Batch 10 */
|
||||||
|
EXECUTE PROCEDURE seed_users_batch(128, 128);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/* Batch 11 */
|
||||||
|
EXECUTE PROCEDURE seed_users_batch(128, 128);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/* Batch 12 */
|
||||||
|
EXECUTE PROCEDURE seed_users_batch(128, 128);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/* Batch 13 */
|
||||||
|
EXECUTE PROCEDURE seed_users_batch(128, 128);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/* Batch 14 */
|
||||||
|
EXECUTE PROCEDURE seed_users_batch(128, 128);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/* Batch 15 */
|
||||||
|
EXECUTE PROCEDURE seed_users_batch(128, 128);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/* Batch 16 — approximately 2 GiB total */
|
||||||
|
EXECUTE PROCEDURE seed_users_batch(128, 128);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/* Batch 17 */
|
||||||
|
EXECUTE PROCEDURE seed_users_batch(128, 128);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/* Batch 18 */
|
||||||
|
EXECUTE PROCEDURE seed_users_batch(128, 128);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/* Batch 19 */
|
||||||
|
EXECUTE PROCEDURE seed_users_batch(128, 128);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/* Batch 20 */
|
||||||
|
EXECUTE PROCEDURE seed_users_batch(128, 128);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/* Batch 21 */
|
||||||
|
EXECUTE PROCEDURE seed_users_batch(128, 128);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/* Batch 22 */
|
||||||
|
EXECUTE PROCEDURE seed_users_batch(128, 128);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/* Batch 23 */
|
||||||
|
EXECUTE PROCEDURE seed_users_batch(128, 128);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/* Batch 24 — approximately 3 GiB total */
|
||||||
|
EXECUTE PROCEDURE seed_users_batch(128, 128);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/* Batch 25 */
|
||||||
|
EXECUTE PROCEDURE seed_users_batch(128, 128);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/* Batch 26 */
|
||||||
|
EXECUTE PROCEDURE seed_users_batch(128, 128);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/* Batch 27 */
|
||||||
|
EXECUTE PROCEDURE seed_users_batch(128, 128);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/* Batch 28 */
|
||||||
|
EXECUTE PROCEDURE seed_users_batch(128, 128);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/* Batch 29 */
|
||||||
|
EXECUTE PROCEDURE seed_users_batch(128, 128);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/* Batch 30 */
|
||||||
|
EXECUTE PROCEDURE seed_users_batch(128, 128);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/* Batch 31 */
|
||||||
|
EXECUTE PROCEDURE seed_users_batch(128, 128);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/* Batch 32 — approximately 4 GiB total */
|
||||||
|
EXECUTE PROCEDURE seed_users_batch(128, 128);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/* Batch 33 */
|
||||||
|
EXECUTE PROCEDURE seed_users_batch(128, 128);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/* Batch 34 */
|
||||||
|
EXECUTE PROCEDURE seed_users_batch(128, 128);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/* Batch 35 */
|
||||||
|
EXECUTE PROCEDURE seed_users_batch(128, 128);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/* Batch 36 */
|
||||||
|
EXECUTE PROCEDURE seed_users_batch(128, 128);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/* Batch 37 */
|
||||||
|
EXECUTE PROCEDURE seed_users_batch(128, 128);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/* Batch 38 */
|
||||||
|
EXECUTE PROCEDURE seed_users_batch(128, 128);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/* Batch 39 */
|
||||||
|
EXECUTE PROCEDURE seed_users_batch(128, 128);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/* Batch 40 — approximately 5 GiB total */
|
||||||
|
EXECUTE PROCEDURE seed_users_batch(128, 128);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
DROP PROCEDURE seed_users_batch;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS user_count,
|
||||||
|
CAST(SUM(OCTET_LENGTH(payload)) / 1073741824.0 AS DECIMAL(18, 2))
|
||||||
|
AS payload_size_gib
|
||||||
|
FROM users;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
+30
-8
@@ -2,13 +2,16 @@
|
|||||||
|
|
||||||
use crate::core::context::Context;
|
use crate::core::context::Context;
|
||||||
use crate::services::backup::BackupService;
|
use crate::services::backup::BackupService;
|
||||||
use crate::services::config::ConfigService;
|
use crate::services::config::{ConfigService, DatabaseConfig};
|
||||||
use crate::services::cron::CronService;
|
use crate::services::cron::CronService;
|
||||||
|
use crate::services::dashboard_config::{collect_configs, load_cache, merge, persist_cache};
|
||||||
use crate::services::restore::RestoreService;
|
use crate::services::restore::RestoreService;
|
||||||
use crate::services::status::StatusService;
|
use crate::services::status::StatusService;
|
||||||
|
use crate::settings::CONFIG;
|
||||||
use crate::utils::common::BackupMethod;
|
use crate::utils::common::BackupMethod;
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tracing::info;
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
pub struct Agent {
|
pub struct Agent {
|
||||||
ctx: Arc<Context>,
|
ctx: Arc<Context>,
|
||||||
@@ -17,6 +20,8 @@ pub struct Agent {
|
|||||||
cron_service: CronService,
|
cron_service: CronService,
|
||||||
backup_service: BackupService,
|
backup_service: BackupService,
|
||||||
restore_service: RestoreService,
|
restore_service: RestoreService,
|
||||||
|
dashboard_cache: Vec<DatabaseConfig>,
|
||||||
|
cache_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Agent {
|
impl Agent {
|
||||||
@@ -28,6 +33,9 @@ impl Agent {
|
|||||||
let backup_service = BackupService::new(ctx.clone());
|
let backup_service = BackupService::new(ctx.clone());
|
||||||
let restore_service = RestoreService::new(ctx.clone());
|
let restore_service = RestoreService::new(ctx.clone());
|
||||||
|
|
||||||
|
let cache_path = PathBuf::from(&CONFIG.data_path).join("dashboard_databases.json");
|
||||||
|
let dashboard_cache = load_cache(&cache_path);
|
||||||
|
|
||||||
Agent {
|
Agent {
|
||||||
ctx,
|
ctx,
|
||||||
config_service,
|
config_service,
|
||||||
@@ -35,19 +43,33 @@ impl Agent {
|
|||||||
cron_service,
|
cron_service,
|
||||||
backup_service,
|
backup_service,
|
||||||
restore_service,
|
restore_service,
|
||||||
|
dashboard_cache,
|
||||||
|
cache_path,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run(&mut self, method: BackupMethod) -> Result<(), Box<dyn std::error::Error>> {
|
pub async fn run(&mut self, method: BackupMethod) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let config = self.config_service.load(None)?;
|
let local = self.config_service.load_optional(None);
|
||||||
let ping_result = self.status_service.ping(&config.databases).await?;
|
|
||||||
|
let merged_in = merge(&local.databases, &self.dashboard_cache);
|
||||||
|
let ping_result = self.status_service.ping(&merged_in.databases).await?;
|
||||||
|
|
||||||
|
self.dashboard_cache = collect_configs(&ping_result);
|
||||||
|
if let Err(e) = persist_cache(&self.cache_path, &self.dashboard_cache) {
|
||||||
|
error!("Failed to persist dashboard cache: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let merged = merge(&local.databases, &self.dashboard_cache);
|
||||||
|
|
||||||
for db in ping_result.databases.iter() {
|
for db in ping_result.databases.iter() {
|
||||||
let database = config
|
let Some(database) = merged
|
||||||
.databases
|
.databases
|
||||||
.iter()
|
.iter()
|
||||||
.find(|cfg_db| cfg_db.generated_id == db.generated_id)
|
.find(|cfg_db| cfg_db.generated_id == db.generated_id)
|
||||||
.unwrap();
|
else {
|
||||||
|
warn!("No config for returned database {}; skipping", db.generated_id);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
info!(
|
info!(
|
||||||
"Generated Id: {} | backup action: {} | restore action: {} | Database Name: {}",
|
"Generated Id: {} | backup action: {} | restore action: {} | Database Name: {}",
|
||||||
db.generated_id, db.data.backup.action, db.data.restore.action, database.name,
|
db.generated_id, db.data.backup.action, db.data.restore.action, database.name,
|
||||||
@@ -59,14 +81,14 @@ impl Agent {
|
|||||||
.backup_service
|
.backup_service
|
||||||
.dispatch(
|
.dispatch(
|
||||||
&db.generated_id,
|
&db.generated_id,
|
||||||
&config,
|
&merged,
|
||||||
method.clone(),
|
method.clone(),
|
||||||
&db.storages,
|
&db.storages,
|
||||||
db.encrypt,
|
db.encrypt,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
} else if db.data.restore.action {
|
} else if db.data.restore.action {
|
||||||
let _ = self.restore_service.dispatch(db, &config).await;
|
let _ = self.restore_service.dispatch(db, &merged).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ pub const EPHEMERAL_LABEL: &str = "io.portabase.ephemeral";
|
|||||||
const HELPER_MOUNT: &str = "/vol";
|
const HELPER_MOUNT: &str = "/vol";
|
||||||
|
|
||||||
pub fn client() -> Result<Docker> {
|
pub fn client() -> Result<Docker> {
|
||||||
Docker::connect_with_unix_defaults().context("Failed to connect to Docker daemon socket")
|
Docker::connect_with_defaults().context("Failed to connect to Docker daemon socket")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_container_id(mountinfo: &str, cgroup: &str) -> Option<String> {
|
pub fn parse_container_id(mountinfo: &str, cgroup: &str) -> Option<String> {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ pub async fn run(
|
|||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let output = Command::new("gbak")
|
let output = Command::new("gbak")
|
||||||
.arg("-b")
|
.arg("-b")
|
||||||
|
.arg("-g")
|
||||||
.arg("-v")
|
.arg("-v")
|
||||||
.arg("-user").arg(&cfg.username)
|
.arg("-user").arg(&cfg.username)
|
||||||
.arg("-password").arg(&cfg.password)
|
.arg("-password").arg(&cfg.password)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ pub async fn run(cfg: DatabaseConfig) -> anyhow::Result<bool> {
|
|||||||
.stdin(Stdio::piped())
|
.stdin(Stdio::piped())
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped())
|
.stderr(Stdio::piped())
|
||||||
|
.kill_on_drop(true)
|
||||||
.spawn()?;
|
.spawn()?;
|
||||||
|
|
||||||
let query = b"SELECT 1 FROM RDB$DATABASE;\nQUIT;\n";
|
let query = b"SELECT 1 FROM RDB$DATABASE;\nQUIT;\n";
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ pub async fn run(cfg: DatabaseConfig, env: HashMap<String, String>) -> anyhow::R
|
|||||||
.arg("--user")
|
.arg("--user")
|
||||||
.arg(cfg.username)
|
.arg(cfg.username)
|
||||||
.arg("ping")
|
.arg("ping")
|
||||||
.envs(env);
|
.envs(env)
|
||||||
|
.kill_on_drop(true);
|
||||||
|
|
||||||
let result = timeout(Duration::from_secs(10), cmd.output()).await;
|
let result = timeout(Duration::from_secs(10), cmd.output()).await;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
use crate::services::config::DatabaseConfig;
|
use crate::services::config::DatabaseConfig;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use mongodb::Client;
|
use mongodb::Client;
|
||||||
|
use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
|
||||||
|
|
||||||
|
const USERINFO_ENCODE: &AsciiSet = &NON_ALPHANUMERIC
|
||||||
|
.remove(b'-')
|
||||||
|
.remove(b'_')
|
||||||
|
.remove(b'.')
|
||||||
|
.remove(b'~');
|
||||||
|
|
||||||
pub async fn connect(cfg: DatabaseConfig) -> Result<Client> {
|
pub async fn connect(cfg: DatabaseConfig) -> Result<Client> {
|
||||||
let uri = get_mongo_uri(cfg)?;
|
let uri = get_mongo_uri(cfg)?;
|
||||||
@@ -16,19 +23,40 @@ pub fn select_mongo_path() -> std::path::PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_mongo_uri(cfg: DatabaseConfig) -> Result<String> {
|
pub fn get_mongo_uri(cfg: DatabaseConfig) -> Result<String> {
|
||||||
if cfg.username.is_empty() || cfg.password.is_empty() {
|
Ok(build_mongo_uri(&cfg, true))
|
||||||
Ok(format!(
|
|
||||||
"mongodb://{}:{}/{}",
|
|
||||||
cfg.host, cfg.port, cfg.database
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
Ok(format!(
|
|
||||||
"mongodb://{}:{}@{}:{}/{}?authSource=admin",
|
|
||||||
cfg.username, cfg.password, cfg.host, cfg.port, cfg.database
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn build_mongo_uri(cfg: &DatabaseConfig, include_db: bool) -> String {
|
||||||
|
let is_srv = cfg.port == 0;
|
||||||
|
let scheme = if is_srv { "mongodb+srv" } else { "mongodb" };
|
||||||
|
let has_auth = !cfg.username.is_empty() && !cfg.password.is_empty();
|
||||||
|
|
||||||
|
let credentials = if has_auth {
|
||||||
|
format!(
|
||||||
|
"{}:{}@",
|
||||||
|
utf8_percent_encode(&cfg.username, USERINFO_ENCODE),
|
||||||
|
utf8_percent_encode(&cfg.password, USERINFO_ENCODE)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let authority = if is_srv {
|
||||||
|
cfg.host.clone()
|
||||||
|
} else {
|
||||||
|
format!("{}:{}", cfg.host, cfg.port)
|
||||||
|
};
|
||||||
|
|
||||||
|
let path = if include_db {
|
||||||
|
format!("/{}", cfg.database)
|
||||||
|
} else {
|
||||||
|
"/".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = if has_auth { "?authSource=admin" } else { "" };
|
||||||
|
|
||||||
|
format!("{}://{}{}{}{}", scheme, credentials, authority, path, query)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn extract_db_name(dry_output: &str) -> Option<String> {
|
pub fn extract_db_name(dry_output: &str) -> Option<String> {
|
||||||
let mut dbs = std::collections::HashSet::new();
|
let mut dbs = std::collections::HashSet::new();
|
||||||
@@ -43,3 +71,70 @@ pub fn extract_db_name(dry_output: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
dbs.into_iter().next()
|
dbs.into_iter().next()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::services::config::{DatabaseConfig, DbType};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
fn cfg(host: &str, port: u16, user: &str, pass: &str) -> DatabaseConfig {
|
||||||
|
DatabaseConfig {
|
||||||
|
name: "t".into(),
|
||||||
|
database: "mydb".into(),
|
||||||
|
db_type: DbType::MongoDB,
|
||||||
|
username: user.into(),
|
||||||
|
password: pass.into(),
|
||||||
|
port,
|
||||||
|
host: host.into(),
|
||||||
|
generated_id: "id".into(),
|
||||||
|
path: String::new(),
|
||||||
|
max_packet_size: String::new(),
|
||||||
|
volume_name: String::new(),
|
||||||
|
container_name: None,
|
||||||
|
options: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn standard_with_auth() {
|
||||||
|
let c = cfg("localhost", 27017, "user", "pass");
|
||||||
|
assert_eq!(
|
||||||
|
build_mongo_uri(&c, true),
|
||||||
|
"mongodb://user:pass@localhost:27017/mydb?authSource=admin"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn standard_no_auth() {
|
||||||
|
let c = cfg("localhost", 27017, "", "");
|
||||||
|
assert_eq!(build_mongo_uri(&c, true), "mongodb://localhost:27017/mydb");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn srv_with_auth() {
|
||||||
|
let c = cfg("cluster.example.mongodb.net", 0, "user", "pass");
|
||||||
|
assert_eq!(
|
||||||
|
build_mongo_uri(&c, true),
|
||||||
|
"mongodb+srv://user:pass@cluster.example.mongodb.net/mydb?authSource=admin"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn srv_no_db_for_dryrun() {
|
||||||
|
let c = cfg("cluster.example.mongodb.net", 0, "user", "pass");
|
||||||
|
assert_eq!(
|
||||||
|
build_mongo_uri(&c, false),
|
||||||
|
"mongodb+srv://user:pass@cluster.example.mongodb.net/?authSource=admin"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encodes_special_chars_in_credentials() {
|
||||||
|
let c = cfg("cluster.example.mongodb.net", 0, "user", "p@ss:w/rd?");
|
||||||
|
assert_eq!(
|
||||||
|
build_mongo_uri(&c, true),
|
||||||
|
"mongodb+srv://user:p%40ss%3Aw%2Frd%3F@cluster.example.mongodb.net/mydb?authSource=admin"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,7 +19,11 @@ pub async fn run(cfg: DatabaseConfig) -> Result<bool> {
|
|||||||
Ok(_) => Ok(true),
|
Ok(_) => Ok(true),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("--- MongoDB Connection Error Details ---");
|
error!("--- MongoDB Connection Error Details ---");
|
||||||
error!("Target Host: {}:{}", cfg.host, cfg.port);
|
if cfg.port == 0 {
|
||||||
|
error!("Target Host: {} (srv)", cfg.host);
|
||||||
|
} else {
|
||||||
|
error!("Target Host: {}:{}", cfg.host, cfg.port);
|
||||||
|
}
|
||||||
error!("Error Kind: {:?}", e.kind);
|
error!("Error Kind: {:?}", e.kind);
|
||||||
error!("Full Error: {}", e);
|
error!("Full Error: {}", e);
|
||||||
error!("Check you database network connectivity");
|
error!("Check you database network connectivity");
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use crate::domain::mongodb::connection::{extract_db_name, get_mongo_uri, select_mongo_path};
|
use crate::domain::mongodb::connection::{
|
||||||
|
build_mongo_uri, extract_db_name, get_mongo_uri, select_mongo_path,
|
||||||
|
};
|
||||||
use crate::services::backup::logger::JobLogger;
|
use crate::services::backup::logger::JobLogger;
|
||||||
use crate::services::config::DatabaseConfig;
|
use crate::services::config::DatabaseConfig;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
@@ -16,13 +18,7 @@ pub async fn run(cfg: DatabaseConfig, restore_file: PathBuf, logger: Arc<JobLogg
|
|||||||
|
|
||||||
let dry_start = Instant::now();
|
let dry_start = Instant::now();
|
||||||
let dry_run = Command::new(&mongorestore)
|
let dry_run = Command::new(&mongorestore)
|
||||||
.arg(format!(
|
.arg(format!("--uri={}", build_mongo_uri(&cfg, false)))
|
||||||
"--uri={}",
|
|
||||||
format!(
|
|
||||||
"mongodb://{}:{}@{}:{}/?authSource=admin",
|
|
||||||
cfg.username, cfg.password, cfg.host, cfg.port
|
|
||||||
)
|
|
||||||
))
|
|
||||||
.arg(format!("--archive={}", restore_file.display()))
|
.arg(format!("--archive={}", restore_file.display()))
|
||||||
.arg("--gzip")
|
.arg("--gzip")
|
||||||
.arg("--dryRun")
|
.arg("--dryRun")
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ pub async fn run(cfg: DatabaseConfig, env: HashMap<String, String>) -> anyhow::R
|
|||||||
.arg("--user")
|
.arg("--user")
|
||||||
.arg(cfg.username)
|
.arg(cfg.username)
|
||||||
.arg("ping")
|
.arg("ping")
|
||||||
.envs(env);
|
.envs(env)
|
||||||
|
.kill_on_drop(true);
|
||||||
|
|
||||||
let result = timeout(Duration::from_secs(10), cmd.output()).await;
|
let result = timeout(Duration::from_secs(10), cmd.output()).await;
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ pub async fn run(cfg: DatabaseConfig) -> Result<bool> {
|
|||||||
|
|
||||||
cmd.arg("PING");
|
cmd.arg("PING");
|
||||||
|
|
||||||
|
cmd.kill_on_drop(true);
|
||||||
|
|
||||||
debug!("Command Ping Redis: {:?}", cmd);
|
debug!("Command Ping Redis: {:?}", cmd);
|
||||||
|
|
||||||
let result = timeout(Duration::from_secs(10), cmd.output()).await;
|
let result = timeout(Duration::from_secs(10), cmd.output()).await;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ pub async fn run(cfg: DatabaseConfig) -> Result<bool> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd.arg("PING");
|
cmd.arg("PING");
|
||||||
|
cmd.kill_on_drop(true);
|
||||||
|
|
||||||
debug!("Command Ping Valkey: {:?}", cmd);
|
debug!("Command Ping Valkey: {:?}", cmd);
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ async fn main() {
|
|||||||
eprintln!("Failed to clean locks on startup: {:?}", e);
|
eprintln!("Failed to clean locks on startup: {:?}", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Best-effort cleanup of ephemeral helper containers orphaned by a crash.
|
|
||||||
match crate::domain::docker_volume::docker::client() {
|
match crate::domain::docker_volume::docker::client() {
|
||||||
Ok(docker) => match crate::domain::docker_volume::docker::sweep_ephemeral(&docker).await {
|
Ok(docker) => match crate::domain::docker_volume::docker::sweep_ephemeral(&docker).await {
|
||||||
Ok(n) if n > 0 => tracing::info!("Removed {n} orphaned ephemeral helper container(s)"),
|
Ok(n) if n > 0 => tracing::info!("Removed {n} orphaned ephemeral helper container(s)"),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
use crate::services::config::DatabaseConfig;
|
||||||
use crate::utils::deserializer::{deserialize_snake_case, string_or_number_to_string};
|
use crate::utils::deserializer::{deserialize_snake_case, string_or_number_to_string};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use toml::Value;
|
use toml::Value;
|
||||||
@@ -39,6 +40,13 @@ pub struct DatabaseStatus {
|
|||||||
pub storages_encrypted: Option<bool>,
|
pub storages_encrypted: Option<bool>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub storages_ciphertext: Option<String>,
|
pub storages_ciphertext: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub config_encrypted: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub config_ciphertext: Option<String>,
|
||||||
|
/// Filled in memory after decrypting `config_ciphertext`; never on the wire.
|
||||||
|
#[serde(skip)]
|
||||||
|
pub resolved_config: Option<DatabaseConfig>,
|
||||||
pub encrypt: bool,
|
pub encrypt: bool,
|
||||||
pub data: DatabaseData,
|
pub data: DatabaseData,
|
||||||
}
|
}
|
||||||
|
|||||||
+136
-128
@@ -1,7 +1,7 @@
|
|||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
use crate::core::context::Context;
|
use crate::core::context::Context;
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json;
|
use serde_json;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
@@ -12,7 +12,7 @@ use toml;
|
|||||||
use tracing::info;
|
use tracing::info;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum DbType {
|
pub enum DbType {
|
||||||
Mysql,
|
Mysql,
|
||||||
@@ -49,7 +49,7 @@ impl DbType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
pub struct DatabaseConfig {
|
pub struct DatabaseConfig {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub database: String,
|
pub database: String,
|
||||||
@@ -68,7 +68,7 @@ pub struct DatabaseConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
pub struct DatabasesConfig {
|
pub struct DatabasesConfig {
|
||||||
pub databases: Vec<DatabaseConfig>,
|
pub databases: Vec<DatabaseConfig>,
|
||||||
}
|
}
|
||||||
@@ -98,6 +98,106 @@ pub struct InputDatabasesConfig {
|
|||||||
pub databases: Vec<InputDatabaseConfig>,
|
pub databases: Vec<InputDatabaseConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn required<T: Clone>(opt: &Option<T>, db_name: &str, field_name: &str) -> Result<T, String> {
|
||||||
|
match opt {
|
||||||
|
Some(v) => Ok(v.clone()),
|
||||||
|
None => Err(format!(
|
||||||
|
"Missing required field '{}' for database '{}'",
|
||||||
|
field_name, db_name
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn optional<T: Clone + Default>(opt: &Option<T>) -> T {
|
||||||
|
opt.clone().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_config(db: InputDatabaseConfig) -> Result<DatabaseConfig, String> {
|
||||||
|
if Uuid::parse_str(&db.generated_id).is_err() {
|
||||||
|
return Err(format!("Invalid UUID for database '{}'", db.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
let username = match db.db_type {
|
||||||
|
DbType::Postgresql
|
||||||
|
| DbType::PostgresqlCluster
|
||||||
|
| DbType::Mysql
|
||||||
|
| DbType::Mariadb
|
||||||
|
| DbType::Mssql => required(&db.username, &db.name, "username")?,
|
||||||
|
_ => optional(&db.username),
|
||||||
|
};
|
||||||
|
let password = match db.db_type {
|
||||||
|
DbType::Postgresql
|
||||||
|
| DbType::PostgresqlCluster
|
||||||
|
| DbType::Mysql
|
||||||
|
| DbType::Mariadb
|
||||||
|
| DbType::Mssql => required(&db.password, &db.name, "password")?,
|
||||||
|
_ => optional(&db.password),
|
||||||
|
};
|
||||||
|
let host = match db.db_type {
|
||||||
|
DbType::Postgresql
|
||||||
|
| DbType::PostgresqlCluster
|
||||||
|
| DbType::Mysql
|
||||||
|
| DbType::Mariadb
|
||||||
|
| DbType::MongoDB
|
||||||
|
| DbType::Redis
|
||||||
|
| DbType::Firebird
|
||||||
|
| DbType::Valkey
|
||||||
|
| DbType::Mssql => required(&db.host, &db.name, "host")?,
|
||||||
|
DbType::Sqlite | DbType::DockerVolume => optional(&db.host),
|
||||||
|
};
|
||||||
|
|
||||||
|
let port = match db.db_type {
|
||||||
|
DbType::Postgresql
|
||||||
|
| DbType::PostgresqlCluster
|
||||||
|
| DbType::Mysql
|
||||||
|
| DbType::Mariadb
|
||||||
|
| DbType::Redis
|
||||||
|
| DbType::Firebird
|
||||||
|
| DbType::Valkey
|
||||||
|
| DbType::Mssql => required(&db.port, &db.name, "port")?,
|
||||||
|
DbType::MongoDB | DbType::Sqlite | DbType::DockerVolume => db.port.unwrap_or(0),
|
||||||
|
};
|
||||||
|
|
||||||
|
let database_name = match db.db_type {
|
||||||
|
DbType::Sqlite | DbType::Redis | DbType::Valkey | DbType::DockerVolume => {
|
||||||
|
optional(&db.database)
|
||||||
|
}
|
||||||
|
DbType::PostgresqlCluster => db
|
||||||
|
.database
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "postgres".to_string()),
|
||||||
|
_ => required(&db.database, &db.name, "database")?,
|
||||||
|
};
|
||||||
|
let path_val = match db.db_type {
|
||||||
|
DbType::Sqlite => required(&db.path, &db.name, "path")?,
|
||||||
|
_ => optional(&db.path),
|
||||||
|
};
|
||||||
|
let max_packet_size = match db.db_type {
|
||||||
|
DbType::Mysql | DbType::Mariadb => db.max_packet_size.unwrap_or_else(|| "512M".to_string()),
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
let volume_name = match db.db_type {
|
||||||
|
DbType::DockerVolume => required(&db.volume_name, &db.name, "volume_name")?,
|
||||||
|
_ => optional(&db.volume_name),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(DatabaseConfig {
|
||||||
|
name: db.name,
|
||||||
|
database: database_name,
|
||||||
|
db_type: db.db_type,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
generated_id: db.generated_id,
|
||||||
|
path: path_val,
|
||||||
|
max_packet_size,
|
||||||
|
volume_name,
|
||||||
|
container_name: db.container_name.clone(),
|
||||||
|
options: db.options.unwrap_or_default(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub struct ConfigService {
|
pub struct ConfigService {
|
||||||
ctx: Arc<Context>,
|
ctx: Arc<Context>,
|
||||||
}
|
}
|
||||||
@@ -107,16 +207,19 @@ impl ConfigService {
|
|||||||
ConfigService { ctx }
|
ConfigService { ctx }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load(&self, file_path: Option<&str>) -> Result<DatabasesConfig, String> {
|
fn resolve_path(file_path: Option<&str>) -> String {
|
||||||
let path: String = if let Some(fp) = file_path {
|
match file_path {
|
||||||
fp.to_string()
|
Some(fp) => fp.to_string(),
|
||||||
} else {
|
None => format!(
|
||||||
format!(
|
|
||||||
"{}/{}",
|
"{}/{}",
|
||||||
crate::settings::CONFIG.data_path,
|
crate::settings::CONFIG.data_path,
|
||||||
crate::settings::CONFIG.databases_config_file
|
crate::settings::CONFIG.databases_config_file
|
||||||
)
|
),
|
||||||
};
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(&self, file_path: Option<&str>) -> Result<DatabasesConfig, String> {
|
||||||
|
let path = Self::resolve_path(file_path);
|
||||||
|
|
||||||
info!("Loading databases config from: {}", path);
|
info!("Loading databases config from: {}", path);
|
||||||
|
|
||||||
@@ -150,128 +253,33 @@ impl ConfigService {
|
|||||||
_ => return Err("Unsupported config file format. Use .json or .toml".to_string()),
|
_ => return Err("Unsupported config file format. Use .json or .toml".to_string()),
|
||||||
};
|
};
|
||||||
|
|
||||||
fn required<T: Clone>(
|
|
||||||
opt: &Option<T>,
|
|
||||||
db_name: &str,
|
|
||||||
field_name: &str,
|
|
||||||
) -> Result<T, String> {
|
|
||||||
match opt {
|
|
||||||
Some(v) => Ok(v.clone()),
|
|
||||||
None => {
|
|
||||||
let msg = format!(
|
|
||||||
"Missing required field '{}' for database '{}'",
|
|
||||||
field_name, db_name
|
|
||||||
);
|
|
||||||
Err(msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn optional<T: Clone>(opt: &Option<T>) -> T
|
|
||||||
where
|
|
||||||
T: Default,
|
|
||||||
{
|
|
||||||
opt.clone().unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut databases = Vec::with_capacity(input_config.databases.len());
|
let mut databases = Vec::with_capacity(input_config.databases.len());
|
||||||
|
|
||||||
for db in input_config.databases {
|
for db in input_config.databases {
|
||||||
if Uuid::parse_str(&db.generated_id).is_err() {
|
databases.push(build_config(db)?);
|
||||||
return Err(format!("Invalid UUID for database '{}'", db.name));
|
|
||||||
}
|
|
||||||
|
|
||||||
let username = match db.db_type {
|
|
||||||
DbType::Postgresql
|
|
||||||
| DbType::PostgresqlCluster
|
|
||||||
| DbType::Mysql
|
|
||||||
| DbType::Mariadb
|
|
||||||
| DbType::Mssql => required(&db.username, &db.name, "username")?,
|
|
||||||
_ => optional(&db.username),
|
|
||||||
};
|
|
||||||
|
|
||||||
let password = match db.db_type {
|
|
||||||
DbType::Postgresql
|
|
||||||
| DbType::PostgresqlCluster
|
|
||||||
| DbType::Mysql
|
|
||||||
| DbType::Mariadb
|
|
||||||
| DbType::Mssql => required(&db.password, &db.name, "password")?,
|
|
||||||
_ => optional(&db.password),
|
|
||||||
};
|
|
||||||
|
|
||||||
let host = match db.db_type {
|
|
||||||
DbType::Postgresql
|
|
||||||
| DbType::PostgresqlCluster
|
|
||||||
| DbType::Mysql
|
|
||||||
| DbType::Mariadb
|
|
||||||
| DbType::MongoDB
|
|
||||||
| DbType::Redis
|
|
||||||
| DbType::Firebird
|
|
||||||
| DbType::Valkey
|
|
||||||
| DbType::Mssql => required(&db.host, &db.name, "host")?,
|
|
||||||
DbType::Sqlite | DbType::DockerVolume => optional(&db.host),
|
|
||||||
};
|
|
||||||
|
|
||||||
let port = match db.db_type {
|
|
||||||
DbType::Postgresql
|
|
||||||
| DbType::PostgresqlCluster
|
|
||||||
| DbType::Mysql
|
|
||||||
| DbType::Mariadb
|
|
||||||
| DbType::MongoDB
|
|
||||||
| DbType::Redis
|
|
||||||
| DbType::Firebird
|
|
||||||
| DbType::Valkey
|
|
||||||
| DbType::Mssql => required(&db.port, &db.name, "port")?,
|
|
||||||
DbType::Sqlite | DbType::DockerVolume => db.port.unwrap_or(0),
|
|
||||||
};
|
|
||||||
|
|
||||||
let database_name = match db.db_type {
|
|
||||||
DbType::Sqlite | DbType::Redis | DbType::Valkey | DbType::DockerVolume => {
|
|
||||||
optional(&db.database)
|
|
||||||
}
|
|
||||||
DbType::PostgresqlCluster => db
|
|
||||||
.database
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| "postgres".to_string()),
|
|
||||||
_ => required(&db.database, &db.name, "database")?,
|
|
||||||
};
|
|
||||||
|
|
||||||
let path_val = match db.db_type {
|
|
||||||
DbType::Sqlite => required(&db.path, &db.name, "path")?,
|
|
||||||
_ => optional(&db.path),
|
|
||||||
};
|
|
||||||
|
|
||||||
let max_packet_size = match db.db_type {
|
|
||||||
DbType::Mysql | DbType::Mariadb => {
|
|
||||||
db.max_packet_size.unwrap_or_else(|| "512M".to_string())
|
|
||||||
}
|
|
||||||
_ => String::new(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let volume_name = match db.db_type {
|
|
||||||
DbType::DockerVolume => required(&db.volume_name, &db.name, "volume_name")?,
|
|
||||||
_ => optional(&db.volume_name),
|
|
||||||
};
|
|
||||||
let container_name = db.container_name.clone();
|
|
||||||
|
|
||||||
databases.push(DatabaseConfig {
|
|
||||||
name: db.name,
|
|
||||||
database: database_name,
|
|
||||||
db_type: db.db_type,
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
host,
|
|
||||||
port,
|
|
||||||
generated_id: db.generated_id,
|
|
||||||
path: path_val,
|
|
||||||
max_packet_size,
|
|
||||||
volume_name,
|
|
||||||
container_name,
|
|
||||||
options: db.options.unwrap_or_default(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Databases: {} instances loaded", databases.len());
|
info!("Databases: {} instances loaded", databases.len());
|
||||||
Ok(DatabasesConfig { databases })
|
Ok(DatabasesConfig { databases })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn load_optional(&self, file_path: Option<&str>) -> DatabasesConfig {
|
||||||
|
let path = Self::resolve_path(file_path);
|
||||||
|
|
||||||
|
if !Path::new(&path).exists() {
|
||||||
|
info!(
|
||||||
|
"No local databases config at {}; using dashboard-defined databases only",
|
||||||
|
path
|
||||||
|
);
|
||||||
|
return DatabasesConfig {
|
||||||
|
databases: Vec::new(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
self.load(file_path).unwrap_or_else(|e| {
|
||||||
|
tracing::warn!(
|
||||||
|
"Local databases config unavailable ({e}); continuing with dashboard-defined databases only"
|
||||||
|
);
|
||||||
|
DatabasesConfig { databases: Vec::new() }
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
use crate::services::api::models::agent::status::PingResult;
|
||||||
|
use crate::services::config::{DatabaseConfig, DatabasesConfig};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub fn merge(local: &[DatabaseConfig], dashboard: &[DatabaseConfig]) -> DatabasesConfig {
|
||||||
|
let mut databases: Vec<DatabaseConfig> = local.to_vec();
|
||||||
|
for d in dashboard {
|
||||||
|
if let Some(slot) = databases
|
||||||
|
.iter_mut()
|
||||||
|
.find(|c| c.generated_id == d.generated_id)
|
||||||
|
{
|
||||||
|
*slot = d.clone();
|
||||||
|
} else {
|
||||||
|
databases.push(d.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DatabasesConfig { databases }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn collect_configs(ping: &PingResult) -> Vec<DatabaseConfig> {
|
||||||
|
ping.databases
|
||||||
|
.iter()
|
||||||
|
.filter_map(|db| db.resolved_config.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_cache(path: &Path) -> Vec<DatabaseConfig> {
|
||||||
|
let contents = match std::fs::read_to_string(path) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => return Vec::new(),
|
||||||
|
};
|
||||||
|
match serde_json::from_str::<DatabasesConfig>(&contents) {
|
||||||
|
Ok(cfg) => cfg.databases,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Dashboard cache at {:?} is corrupt ({e}); ignoring", path);
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn persist_cache(path: &Path, databases: &[DatabaseConfig]) -> std::io::Result<()> {
|
||||||
|
let wrapper = DatabasesConfig {
|
||||||
|
databases: databases.to_vec(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string_pretty(&wrapper)
|
||||||
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
std::fs::write(&tmp, json)?;
|
||||||
|
std::fs::rename(&tmp, path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ pub mod api;
|
|||||||
pub mod backup;
|
pub mod backup;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod cron;
|
pub mod cron;
|
||||||
|
pub mod dashboard_config;
|
||||||
pub mod restore;
|
pub mod restore;
|
||||||
pub mod status;
|
pub mod status;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
|
|||||||
+26
-1
@@ -3,9 +3,10 @@
|
|||||||
use crate::core::context::Context;
|
use crate::core::context::Context;
|
||||||
use crate::domain::factory::DatabaseFactory;
|
use crate::domain::factory::DatabaseFactory;
|
||||||
use crate::services::api::endpoints::status::DatabasePayload;
|
use crate::services::api::endpoints::status::DatabasePayload;
|
||||||
|
use crate::services::api::models::agent::status::DatabaseStatus;
|
||||||
use crate::services::api::models::agent::status::DatabaseStorage;
|
use crate::services::api::models::agent::status::DatabaseStorage;
|
||||||
use crate::services::api::models::agent::status::PingResult;
|
use crate::services::api::models::agent::status::PingResult;
|
||||||
use crate::services::config::DatabaseConfig;
|
use crate::services::config::{build_config, DatabaseConfig, InputDatabaseConfig};
|
||||||
use crate::settings::CONFIG;
|
use crate::settings::CONFIG;
|
||||||
use crate::utils::file::decrypt_json_gcm;
|
use crate::utils::file::decrypt_json_gcm;
|
||||||
use futures_util::future::try_join_all;
|
use futures_util::future::try_join_all;
|
||||||
@@ -14,6 +15,26 @@ use std::error::Error;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
|
pub fn resolve_dashboard_config(
|
||||||
|
status: &mut DatabaseStatus,
|
||||||
|
master_key_b64: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if status.config_encrypted != Some(true) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let ciphertext = status
|
||||||
|
.config_ciphertext
|
||||||
|
.as_deref()
|
||||||
|
.ok_or("config_encrypted set but config_ciphertext missing")?;
|
||||||
|
|
||||||
|
let plaintext = decrypt_json_gcm(ciphertext, master_key_b64)
|
||||||
|
.map_err(|e| format!("Failed to decrypt config: {e}"))?;
|
||||||
|
let input: InputDatabaseConfig = serde_json::from_slice(&plaintext)
|
||||||
|
.map_err(|e| format!("Failed to parse decrypted config: {e}"))?;
|
||||||
|
status.resolved_config = Some(build_config(input)?);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub struct StatusService {
|
pub struct StatusService {
|
||||||
ctx: Arc<Context>,
|
ctx: Arc<Context>,
|
||||||
client: Client,
|
client: Client,
|
||||||
@@ -67,6 +88,10 @@ impl StatusService {
|
|||||||
db.storages = serde_json::from_slice::<Vec<DatabaseStorage>>(&plaintext)
|
db.storages = serde_json::from_slice::<Vec<DatabaseStorage>>(&plaintext)
|
||||||
.map_err(|e| format!("Failed to parse decrypted storages: {e}"))?;
|
.map_err(|e| format!("Failed to parse decrypted storages: {e}"))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Err(e) = resolve_dashboard_config(db, &edge_key.master_key_b64) {
|
||||||
|
tracing::warn!("Skipping dashboard config for {}: {e}", db.generated_id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ use aws_config::retry::RetryConfig;
|
|||||||
use aws_sdk_s3 as s3;
|
use aws_sdk_s3 as s3;
|
||||||
use aws_sdk_s3::config::BehaviorVersion;
|
use aws_sdk_s3::config::BehaviorVersion;
|
||||||
use aws_sdk_s3::config::Region;
|
use aws_sdk_s3::config::Region;
|
||||||
|
use aws_sdk_s3::config::RequestChecksumCalculation;
|
||||||
use aws_sdk_s3::config::retry::ReconnectMode;
|
use aws_sdk_s3::config::retry::ReconnectMode;
|
||||||
|
use aws_sdk_s3::error::DisplayErrorContext;
|
||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
|
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
@@ -135,6 +137,7 @@ impl StorageProvider for S3Provider {
|
|||||||
.credentials_provider(credentials)
|
.credentials_provider(credentials)
|
||||||
.region(region)
|
.region(region)
|
||||||
.force_path_style(true)
|
.force_path_style(true)
|
||||||
|
.request_checksum_calculation(RequestChecksumCalculation::WhenRequired)
|
||||||
.endpoint_url(endpoint)
|
.endpoint_url(endpoint)
|
||||||
.behavior_version(BehaviorVersion::latest())
|
.behavior_version(BehaviorVersion::latest())
|
||||||
.build();
|
.build();
|
||||||
@@ -164,11 +167,12 @@ impl StorageProvider for S3Provider {
|
|||||||
{
|
{
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Failed to create multipart upload: {}", e);
|
let detail = DisplayErrorContext(&e).to_string();
|
||||||
|
error!("Failed to create multipart upload: {}", detail);
|
||||||
return UploadResult {
|
return UploadResult {
|
||||||
storage_id: storage.id.clone(),
|
storage_id: storage.id.clone(),
|
||||||
success: false,
|
success: false,
|
||||||
error: Some(e.to_string()),
|
error: Some(detail),
|
||||||
remote_file_path: None,
|
remote_file_path: None,
|
||||||
total_size: None,
|
total_size: None,
|
||||||
};
|
};
|
||||||
@@ -251,7 +255,8 @@ impl StorageProvider for S3Provider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Failed to upload part {}: {}", part_number, e);
|
let detail = DisplayErrorContext(&e).to_string();
|
||||||
|
error!("Failed to upload part {}: {}", part_number, detail);
|
||||||
let _ = client
|
let _ = client
|
||||||
.abort_multipart_upload()
|
.abort_multipart_upload()
|
||||||
.bucket(bucket)
|
.bucket(bucket)
|
||||||
@@ -262,7 +267,7 @@ impl StorageProvider for S3Provider {
|
|||||||
return UploadResult {
|
return UploadResult {
|
||||||
storage_id: storage.id.clone(),
|
storage_id: storage.id.clone(),
|
||||||
success: false,
|
success: false,
|
||||||
error: Some(e.to_string()),
|
error: Some(detail),
|
||||||
remote_file_path: None,
|
remote_file_path: None,
|
||||||
total_size: None,
|
total_size: None,
|
||||||
};
|
};
|
||||||
@@ -317,7 +322,8 @@ impl StorageProvider for S3Provider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Failed to complete multipart upload: {}", e);
|
let detail = DisplayErrorContext(&e).to_string();
|
||||||
|
error!("Failed to complete multipart upload: {}", detail);
|
||||||
let _ = client
|
let _ = client
|
||||||
.abort_multipart_upload()
|
.abort_multipart_upload()
|
||||||
.bucket(bucket)
|
.bucket(bucket)
|
||||||
@@ -328,7 +334,7 @@ impl StorageProvider for S3Provider {
|
|||||||
UploadResult {
|
UploadResult {
|
||||||
storage_id: storage.id.clone(),
|
storage_id: storage.id.clone(),
|
||||||
success: false,
|
success: false,
|
||||||
error: Some(e.to_string()),
|
error: Some(detail),
|
||||||
remote_file_path: None,
|
remote_file_path: None,
|
||||||
total_size: None,
|
total_size: None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,3 +148,98 @@ fn database_status_encrypted_envelope() {
|
|||||||
assert_eq!(status.storages_encrypted, Some(true));
|
assert_eq!(status.storages_encrypted, Some(true));
|
||||||
assert_eq!(status.storages_ciphertext.as_deref(), Some("AQIDBA=="));
|
assert_eq!(status.storages_ciphertext.as_deref(), Some("AQIDBA=="));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn database_status_defaults_config_fields_absent() {
|
||||||
|
let json = r#"{
|
||||||
|
"dbms": "postgresql",
|
||||||
|
"generatedId": "16678159-ff7e-4c97-8c83-0adeff214681",
|
||||||
|
"encrypt": false,
|
||||||
|
"data": { "backup": { "action": false, "cron": null },
|
||||||
|
"restore": { "action": false, "file": null, "metaFile": null, "size": null } }
|
||||||
|
}"#;
|
||||||
|
let status: crate::services::api::models::agent::status::DatabaseStatus =
|
||||||
|
serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(status.config_encrypted, None);
|
||||||
|
assert!(status.config_ciphertext.is_none());
|
||||||
|
assert!(status.resolved_config.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_dashboard_config_decrypts_full_entry() {
|
||||||
|
use crate::services::status::resolve_dashboard_config;
|
||||||
|
use base64::{engine::general_purpose, Engine};
|
||||||
|
|
||||||
|
// 32-byte master key, base64 STANDARD (matches decrypt_json_gcm).
|
||||||
|
let master_key_b64 = general_purpose::STANDARD.encode([7u8; 32]);
|
||||||
|
|
||||||
|
// Full agent-entry shape the dashboard encrypts.
|
||||||
|
let entry = r#"{
|
||||||
|
"name": "Dashboard PG",
|
||||||
|
"type": "postgresql",
|
||||||
|
"database": "app",
|
||||||
|
"username": "postgres",
|
||||||
|
"password": "s3cret",
|
||||||
|
"port": 5432,
|
||||||
|
"host": "10.0.0.10",
|
||||||
|
"generated_id": "16678159-ff7e-4c97-8c83-0adeff214681"
|
||||||
|
}"#;
|
||||||
|
let ciphertext = encrypt_json_gcm(entry.as_bytes(), &master_key_b64);
|
||||||
|
|
||||||
|
let mut status: crate::services::api::models::agent::status::DatabaseStatus =
|
||||||
|
serde_json::from_str(
|
||||||
|
r#"{
|
||||||
|
"dbms": "postgresql",
|
||||||
|
"generatedId": "16678159-ff7e-4c97-8c83-0adeff214681",
|
||||||
|
"encrypt": false,
|
||||||
|
"config_encrypted": true,
|
||||||
|
"config_ciphertext": "PLACEHOLDER",
|
||||||
|
"data": { "backup": { "action": false, "cron": null },
|
||||||
|
"restore": { "action": false, "file": null, "metaFile": null, "size": null } }
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
status.config_ciphertext = Some(ciphertext);
|
||||||
|
|
||||||
|
resolve_dashboard_config(&mut status, &master_key_b64).unwrap();
|
||||||
|
|
||||||
|
let cfg = status.resolved_config.expect("resolved");
|
||||||
|
assert_eq!(cfg.name, "Dashboard PG");
|
||||||
|
assert_eq!(cfg.password, "s3cret");
|
||||||
|
assert_eq!(cfg.host, "10.0.0.10");
|
||||||
|
assert_eq!(cfg.db_type.as_str(), "postgresql");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_dashboard_config_noop_when_not_encrypted() {
|
||||||
|
use crate::services::status::resolve_dashboard_config;
|
||||||
|
let mut status: crate::services::api::models::agent::status::DatabaseStatus =
|
||||||
|
serde_json::from_str(
|
||||||
|
r#"{
|
||||||
|
"dbms": "postgresql",
|
||||||
|
"generatedId": "16678159-ff7e-4c97-8c83-0adeff214681",
|
||||||
|
"encrypt": false,
|
||||||
|
"data": { "backup": { "action": false, "cron": null },
|
||||||
|
"restore": { "action": false, "file": null, "metaFile": null, "size": null } }
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
resolve_dashboard_config(&mut status, "unused").unwrap();
|
||||||
|
assert!(status.resolved_config.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encrypt_json_gcm(plaintext: &[u8], master_key_b64: &str) -> String {
|
||||||
|
use aes_gcm::aead::{Aead, KeyInit};
|
||||||
|
use aes_gcm::{Aes256Gcm, Key, Nonce};
|
||||||
|
use base64::{engine::general_purpose, Engine};
|
||||||
|
|
||||||
|
let key_bytes = general_purpose::STANDARD.decode(master_key_b64).unwrap();
|
||||||
|
let key = Key::<Aes256Gcm>::try_from(key_bytes.as_slice()).unwrap();
|
||||||
|
let cipher = Aes256Gcm::new(&key);
|
||||||
|
let nonce_bytes = [0u8; 12];
|
||||||
|
let nonce = Nonce::try_from(&nonce_bytes[..]).unwrap();
|
||||||
|
let ct = cipher.encrypt(&nonce, plaintext).unwrap();
|
||||||
|
let mut data = nonce_bytes.to_vec();
|
||||||
|
data.extend_from_slice(&ct);
|
||||||
|
general_purpose::STANDARD.encode(data)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
use crate::core::context::Context;
|
use crate::core::context::Context;
|
||||||
use crate::services::api::ApiClient;
|
use crate::services::api::ApiClient;
|
||||||
use crate::services::config::ConfigService;
|
use crate::services::config::ConfigService;
|
||||||
|
use crate::services::config::{build_config, DatabasesConfig, InputDatabaseConfig};
|
||||||
use crate::utils::edge_key::EdgeKey;
|
use crate::utils::edge_key::EdgeKey;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tempfile::NamedTempFile;
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
// `ConfigService::load` never touches `self.ctx` on the `Some(file_path)` path,
|
|
||||||
// so the values here don't matter — but `Context::new()` panics without an
|
|
||||||
// `EDGE_KEY` env var, so build the struct directly (mirrors
|
|
||||||
// backup_uploader_tests.rs's `ctx_pointing_at`).
|
|
||||||
fn test_context() -> Arc<Context> {
|
fn test_context() -> Arc<Context> {
|
||||||
Arc::new(Context {
|
Arc::new(Context {
|
||||||
edge_key: EdgeKey {
|
edge_key: EdgeKey {
|
||||||
@@ -264,3 +261,73 @@ fn docker_volume_requires_volume_name() {
|
|||||||
let err = service.load(Some(file.path().to_str().unwrap())).unwrap_err();
|
let err = service.load(Some(file.path().to_str().unwrap())).unwrap_err();
|
||||||
assert!(err.contains("volume_name"), "error was: {err}");
|
assert!(err.contains("volume_name"), "error was: {err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_config_applies_type_defaults() {
|
||||||
|
let input: InputDatabaseConfig = serde_json::from_str(
|
||||||
|
r#"{
|
||||||
|
"name": "cluster1",
|
||||||
|
"type": "postgresql-cluster",
|
||||||
|
"username": "postgres",
|
||||||
|
"password": "p",
|
||||||
|
"port": 5432,
|
||||||
|
"host": "localhost",
|
||||||
|
"generated_id": "16678159-ff7e-4c97-8c83-0adeff214681"
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let cfg = build_config(input).unwrap();
|
||||||
|
assert_eq!(cfg.db_type.as_str(), "postgresql-cluster");
|
||||||
|
assert_eq!(cfg.database, "postgres"); // cluster default
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_config_rejects_missing_required_field() {
|
||||||
|
let input: InputDatabaseConfig = serde_json::from_str(
|
||||||
|
r#"{
|
||||||
|
"name": "pg",
|
||||||
|
"type": "postgresql",
|
||||||
|
"username": "postgres",
|
||||||
|
"port": 5432,
|
||||||
|
"host": "localhost",
|
||||||
|
"generated_id": "16678159-ff7e-4c97-8c83-0adeff214681"
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let err = build_config(input).unwrap_err();
|
||||||
|
assert!(err.contains("password"), "unexpected error: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_optional_returns_empty_when_file_missing() {
|
||||||
|
let service = ConfigService::new(test_context());
|
||||||
|
let cfg = service.load_optional(Some("/nonexistent/path/does-not-exist.json"));
|
||||||
|
assert!(cfg.databases.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn databases_config_roundtrips_through_serde() {
|
||||||
|
let input: InputDatabaseConfig = serde_json::from_str(
|
||||||
|
r#"{
|
||||||
|
"name": "pg",
|
||||||
|
"type": "postgresql",
|
||||||
|
"database": "app",
|
||||||
|
"username": "postgres",
|
||||||
|
"password": "secret",
|
||||||
|
"port": 5432,
|
||||||
|
"host": "localhost",
|
||||||
|
"generated_id": "16678159-ff7e-4c97-8c83-0adeff214681"
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let cfg = build_config(input).unwrap();
|
||||||
|
let wrapped = DatabasesConfig { databases: vec![cfg] };
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&wrapped).unwrap();
|
||||||
|
let back: DatabasesConfig = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(back.databases[0].name, "pg");
|
||||||
|
assert_eq!(back.databases[0].db_type.as_str(), "postgresql");
|
||||||
|
assert_eq!(back.databases[0].password, "secret");
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
use crate::services::config::{build_config, DatabaseConfig, InputDatabaseConfig};
|
||||||
|
use crate::services::dashboard_config::merge;
|
||||||
|
use crate::services::dashboard_config::{load_cache, persist_cache};
|
||||||
|
|
||||||
|
fn cfg(name: &str, gen_id: &str, host: &str) -> DatabaseConfig {
|
||||||
|
let json = format!(
|
||||||
|
r#"{{ "name": "{name}", "type": "postgresql", "database": "app",
|
||||||
|
"username": "u", "password": "p", "port": 5432,
|
||||||
|
"host": "{host}", "generated_id": "{gen_id}" }}"#
|
||||||
|
);
|
||||||
|
let input: InputDatabaseConfig = serde_json::from_str(&json).unwrap();
|
||||||
|
build_config(input).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
const ID_A: &str = "16678159-ff7e-4c97-8c83-0adeff214681";
|
||||||
|
const ID_B: &str = "16678124-ff7e-4c97-8c83-0adeff214681";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_keeps_local_only_databases() {
|
||||||
|
let local = vec![cfg("local-a", ID_A, "local-host")];
|
||||||
|
let merged = merge(&local, &[]);
|
||||||
|
assert_eq!(merged.databases.len(), 1);
|
||||||
|
assert_eq!(merged.databases[0].host, "local-host");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_appends_dashboard_only_databases() {
|
||||||
|
let local = vec![cfg("local-a", ID_A, "local-host")];
|
||||||
|
let dashboard = vec![cfg("dash-b", ID_B, "dash-host")];
|
||||||
|
let merged = merge(&local, &dashboard);
|
||||||
|
assert_eq!(merged.databases.len(), 2);
|
||||||
|
assert!(merged.databases.iter().any(|d| d.generated_id == ID_B));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_dashboard_wins_on_id_collision() {
|
||||||
|
let local = vec![cfg("local-a", ID_A, "local-host")];
|
||||||
|
let dashboard = vec![cfg("dash-a", ID_A, "dash-host")];
|
||||||
|
let merged = merge(&local, &dashboard);
|
||||||
|
assert_eq!(merged.databases.len(), 1);
|
||||||
|
assert_eq!(merged.databases[0].host, "dash-host"); // dashboard wins
|
||||||
|
assert_eq!(merged.databases[0].name, "dash-a");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cache_roundtrips() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("dashboard_databases.json");
|
||||||
|
|
||||||
|
let dbs = vec![cfg("dash-a", ID_A, "dash-host")];
|
||||||
|
persist_cache(&path, &dbs).unwrap();
|
||||||
|
|
||||||
|
let loaded = load_cache(&path);
|
||||||
|
assert_eq!(loaded.len(), 1);
|
||||||
|
assert_eq!(loaded[0].generated_id, ID_A);
|
||||||
|
assert_eq!(loaded[0].host, "dash-host");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_cache_missing_file_is_empty() {
|
||||||
|
let loaded = load_cache(std::path::Path::new("/nonexistent/dashboard_databases.json"));
|
||||||
|
assert!(loaded.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_cache_corrupt_file_is_empty() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("dashboard_databases.json");
|
||||||
|
std::fs::write(&path, b"{ this is not valid json").unwrap();
|
||||||
|
|
||||||
|
let loaded = load_cache(&path);
|
||||||
|
assert!(loaded.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persist_cache_leaves_no_tmp_file() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("dashboard_databases.json");
|
||||||
|
persist_cache(&path, &[cfg("dash-a", ID_A, "h")]).unwrap();
|
||||||
|
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
assert!(!tmp.exists(), "temp file should have been renamed away");
|
||||||
|
assert!(path.exists());
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
mod api_models_tests;
|
mod api_models_tests;
|
||||||
mod backup_uploader_tests;
|
mod backup_uploader_tests;
|
||||||
mod config_tests;
|
mod config_tests;
|
||||||
|
mod dashboard_config_tests;
|
||||||
|
|||||||
Reference in New Issue
Block a user