mirror of
https://github.com/jkaninda/mysql-bkup.git
synced 2026-11-13 08:37:15 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cb390bfbc | ||
|
|
dea2eedc35 | ||
|
|
28de0d7b52 | ||
|
|
ccb3fcdf26 | ||
|
|
fff1e1f9a9 | ||
|
|
e7a50743ed | ||
|
|
47220bf005 | ||
|
|
0ae9cffee0 | ||
|
|
0b08e0498a | ||
|
|
aef2395272 | ||
|
|
69f4795adb | ||
|
|
f5df2081f1 | ||
|
|
a1c991b363 | ||
|
|
416e094065 | ||
|
|
8ac6ffc394 | ||
|
|
59296c0c17 | ||
|
|
19b64bbad1 | ||
|
|
640e11b924 | ||
|
|
a746366348 | ||
|
|
64d57a077e | ||
|
|
995db83b7c | ||
|
|
5d5cbea60b | ||
|
|
2cb5603b88 | ||
|
|
548de725dd | ||
|
|
37b2867974 | ||
|
|
d6c01c9a4a | ||
|
|
bd92cc6844 | ||
|
|
3006abac3e | ||
|
|
3a16f6929c | ||
|
|
2f3ed7d0d8 | ||
|
|
a92bba05e4 | ||
|
|
7b565d54bd | ||
|
|
d10321dac6 | ||
|
|
d151c50caa | ||
|
|
07ecec57b3 | ||
|
|
bc4aab6ed0 | ||
|
|
12c17c18d6 | ||
|
|
80cd70e153 | ||
|
|
80abdb4299 | ||
|
|
72b21f00e6 | ||
|
|
3439c74257 | ||
|
|
4110ff4e64 | ||
|
|
cb299a35bf | ||
|
|
ba7c096bf3 | ||
|
|
6ac8dcef9e | ||
|
|
6bcc5d6bd4 | ||
|
|
fd35fabf97 | ||
|
|
23cce10e8c | ||
|
|
e666466d27 | ||
|
|
36bca254a9 | ||
|
|
ad18d42145 | ||
|
|
d9d44c2798 | ||
|
|
300a592508 | ||
|
|
be82e841e7 | ||
|
|
a73a365ebf | ||
|
|
75e965c0c5 | ||
|
|
fc60ddb308 | ||
|
|
573ef15ef3 | ||
|
|
b1776d3689 | ||
|
|
376d47f738 | ||
|
|
eb6268f8ec | ||
|
|
731e2d789d | ||
|
|
6300a8f2dd | ||
|
|
cd827a9277 | ||
|
|
71cf3fae85 | ||
|
|
528282bbd4 | ||
|
|
002c93a796 | ||
|
|
b6192f4c42 | ||
|
|
d5061453b0 | ||
|
|
0bc7497512 | ||
|
|
489dfdf842 | ||
|
|
907e70d552 | ||
|
|
696477fe5c | ||
|
|
56a8b51660 | ||
|
|
c76a00139c | ||
|
|
0f43871765 | ||
|
|
9ba6abe3f4 | ||
|
|
764583d88f | ||
|
|
dbf4dc596a | ||
|
|
06c89a9b78 | ||
|
|
ec8bdd806c | ||
|
|
828b11c6dd | ||
|
|
1d01e13909 | ||
|
|
bd65db2418 | ||
|
|
75b809511e | ||
|
|
fc028a2c55 | ||
|
|
7fa0c6a118 | ||
|
|
661702a97e | ||
|
|
dd5f33f17d | ||
|
|
b7cdfebd9c | ||
|
|
4b93becdf2 | ||
|
|
748cccec58 | ||
|
|
3e8bfabc44 | ||
|
|
777b59fd7c | ||
|
|
2b25f39c0a | ||
|
|
e5ba397bb4 | ||
|
|
3a1bfc512d | ||
|
|
b7b09ad6fd | ||
|
|
1206140a67 | ||
|
|
24573a96ad | ||
|
|
fff0b55722 | ||
|
|
68322e6b9f | ||
|
|
0f28772659 | ||
|
|
b95ccf3905 | ||
|
|
a06872834f | ||
|
|
393168c6c5 | ||
|
|
5b9ec8a224 | ||
|
|
2c3f2f4a46 | ||
|
|
0df14f37b4 | ||
|
|
1b60ca6fd2 | ||
|
|
d880f40108 | ||
|
|
c845b36797 | ||
|
|
63d615f838 | ||
|
|
6f31d35df2 | ||
|
|
f36d01cc96 | ||
|
|
07b7f54a75 | ||
|
|
7ff9a32f08 | ||
|
|
95a81cb6b7 | ||
|
|
057d5277b0 | ||
|
|
8e58d7a4c3 | ||
|
|
4bd7d9fa72 | ||
|
|
156f22f1e5 | ||
|
|
fd444293b4 | ||
|
|
1940ceba9a | ||
|
|
07d580a8a9 | ||
|
|
9a261b22ec | ||
|
|
e7a58f0569 | ||
|
|
1b529725d7 | ||
|
|
d8c73560b8 | ||
|
|
d5a0adc981 | ||
|
|
6df3bae9e2 | ||
|
|
f7d624fd15 | ||
|
|
1e9e1ed951 | ||
|
|
917ba8947f | ||
|
|
94a1dcdff7 | ||
|
|
f70e549b16 | ||
|
|
607478fcc6 | ||
|
|
2862e504f5 | ||
|
|
29420ee13e | ||
|
|
f53272ccf0 | ||
|
|
c360441445 | ||
|
|
f6916231f7 | ||
|
|
afd4afc83b | ||
|
|
9016a9ec7a | ||
|
|
4ecd96e75c | ||
|
|
8a88e4a727 | ||
|
|
62f86adea9 | ||
|
|
eb414d818c | ||
|
|
6721cc430d | ||
|
|
8e20e9595f | ||
|
|
02e3267237 | ||
|
|
448ef4d988 | ||
|
|
70ac78c2cd | ||
|
|
72f5ef4839 | ||
|
|
6a51f591a5 | ||
|
|
d55ade3c21 | ||
|
|
cdbd6dcd6a | ||
|
|
307e18d9ff | ||
|
|
8d366f0302 | ||
|
|
05e32c3cc1 | ||
|
|
edd13907d0 | ||
|
|
7cb1c50927 | ||
|
|
f545704b02 | ||
|
|
90f5391b24 | ||
|
|
ca241b4fef | ||
|
|
3911296921 | ||
|
|
8d04d276ba | ||
|
|
221079e0ea | ||
|
|
590b2d8bc6 | ||
|
|
d2aeb55ebc | ||
|
|
431be36210 | ||
|
|
ef2c5c80cd | ||
|
|
3a0137d6ea | ||
|
|
8afb5ace40 | ||
|
|
5569258a71 | ||
|
|
f3ec395e37 | ||
|
|
ba432997c8 | ||
|
|
dc20ea9635 | ||
|
|
40557af437 | ||
|
|
1dcb9586a6 | ||
|
|
2c6336e84a | ||
|
|
c16ee3a492 | ||
|
|
3f7d28ea49 | ||
|
|
cea1ef9c3b | ||
|
|
56c271bc29 | ||
|
|
45c30dca5f | ||
|
|
b0ae212578 | ||
|
|
6e2d3a9f21 | ||
|
|
dd314aa4cb | ||
|
|
24ccdaa671 | ||
|
|
45e3452376 | ||
|
|
3527b4cdcd | ||
|
|
dc6fe2f4b9 | ||
|
|
f0afc0f4e0 | ||
|
|
7d7c813bb0 | ||
|
|
6b8491cdc0 | ||
|
|
a1dd6e3f58 | ||
|
|
86ba3530c9 | ||
|
|
e1f3b15003 | ||
|
|
1577e92a66 | ||
|
|
7b67f88769 | ||
|
|
043233dabe | ||
|
|
d6652cfb75 | ||
|
|
140ed608ab | ||
|
|
98211a27b8 | ||
|
|
4e4d45e555 | ||
|
|
01e41acb5c | ||
|
|
3dce2017f8 | ||
|
|
ed2f1b8d9c | ||
|
|
b64875df21 | ||
|
|
fc90507b3f | ||
|
|
df0efd24d3 | ||
|
|
e5dd7e76ce | ||
|
|
12fbb67a09 | ||
|
|
df490af7b6 | ||
|
|
d930c3e2f6 | ||
|
|
e4258cb12e | ||
|
|
4c44166921 | ||
|
|
554df819ab | ||
|
|
ca5633882e | ||
|
|
c5cca82841 | ||
|
|
bbd5422089 | ||
|
|
d72156f890 | ||
|
|
909a50dbe7 | ||
|
|
94ceb71da2 | ||
|
|
fe05fe5110 | ||
|
|
dabba2050a | ||
|
|
47e1ac407b | ||
|
|
28f6ed3a82 | ||
|
|
504926c7cd | ||
|
|
737f473f92 | ||
|
|
300d2a8205 | ||
|
|
a4ad0502cf | ||
|
|
f344867edf | ||
|
|
d774584f64 | ||
|
|
96927cd57e | ||
|
|
ceacfa1d9d | ||
|
|
9380a18b45 | ||
|
|
d186071df9 | ||
|
|
71429b0e1a | ||
|
|
0bed86ded4 | ||
|
|
e891801125 | ||
|
|
01cf8a3392 | ||
|
|
efea81833a | ||
|
|
1cbf65d686 | ||
|
|
73d19913f8 | ||
|
|
b0224e43ef | ||
|
|
fa0485bb5a | ||
|
|
65ef6d3e8f | ||
|
|
a7b6abb101 | ||
|
|
3b21c109bc | ||
|
|
a50a1ef6f9 | ||
|
|
76bbfa35c4 | ||
|
|
599d93bef4 | ||
|
|
247e90f73e | ||
|
|
7d544aca68 | ||
|
|
1722ee0eeb | ||
|
|
726fd14831 | ||
|
|
fdc88e6064 | ||
|
|
2ba1b516e9 | ||
|
|
301594676b | ||
|
|
d06f2f2d7e | ||
|
|
2f06bd1c3a | ||
|
|
f383f5559d | ||
|
|
3725809d28 | ||
|
|
b1598ef7d0 | ||
|
|
e4a83b9851 | ||
|
|
4b2527f416 | ||
|
|
e97fc7512a | ||
|
|
7912ce46ed | ||
|
|
050f5e81bc | ||
|
|
b39e97b77d | ||
|
|
cbb73ae89b | ||
|
|
29a58aa26d |
No files matched your search
@@ -0,0 +1,30 @@
|
||||
# Only the Go sources are needed to build the image.
|
||||
.git
|
||||
.github
|
||||
.idea
|
||||
.history
|
||||
.DS_Store
|
||||
.env
|
||||
.env.*
|
||||
*.env
|
||||
backup/
|
||||
backups/
|
||||
data/
|
||||
bin/
|
||||
dist/
|
||||
vendor/
|
||||
docs/
|
||||
examples/
|
||||
migrations/
|
||||
test.md
|
||||
compose.yaml
|
||||
docker-compose*.yaml
|
||||
# Binaries and dumps
|
||||
mysql-bkup
|
||||
mysql-bkup-*
|
||||
*.sql
|
||||
*.sql.gz
|
||||
*.gpg
|
||||
*.age
|
||||
*.tar
|
||||
*.gz
|
||||
@@ -0,0 +1,87 @@
|
||||
### Database
|
||||
DB_HOST=
|
||||
DB_PORT=3306
|
||||
DB_USER=
|
||||
DB_PASSWORD=
|
||||
DB_NAME=
|
||||
#DB_SSL_MODE=disabled
|
||||
TZ=Europe/Paris
|
||||
|
||||
### Database Migration
|
||||
#TARGET_DB_HOST=
|
||||
#TARGET_DB_PORT=3306
|
||||
#TARGET_DB_NAME=
|
||||
#DB_SSL_MODE=disabled
|
||||
#TARGET_DB_USER=
|
||||
#TARGET_DB_PASSWORD=
|
||||
|
||||
### Backup restoration
|
||||
#FILE_NAME=
|
||||
|
||||
### AWS S3 Storage
|
||||
#AWS_ACCESS_KEY=
|
||||
#AWS_SECRET_KEY=
|
||||
#AWS_S3_BUCKET_NAME=
|
||||
#AWS_S3_ENDPOINT=
|
||||
#AWS_REGION=
|
||||
#REMOTE_PATH=
|
||||
#AWS_DISABLE_SSL=false
|
||||
#AWS_FORCE_PATH_STYLE=true
|
||||
|
||||
### Backup Cron Expression
|
||||
#BACKUP_CRON_EXPRESSION=@midnight
|
||||
##Delete old backup created more than specified days ago
|
||||
#BACKUP_RETENTION_DAYS=7
|
||||
#BACKUP_RETENTION_COUNT=10
|
||||
|
||||
####SSH Storage
|
||||
#SFTP_HOST=
|
||||
#SFTP_PORT=22
|
||||
#SFTP_USER=
|
||||
#SFTP_PASSWORD=
|
||||
#SFTP_IDENTITY_FILE=/tmp/id_ed25519
|
||||
#SFTP_KNOWN_HOSTS=/config/known_hosts
|
||||
|
||||
####FTP Storage
|
||||
#FTP_PASSWORD=
|
||||
#FTP_HOST=
|
||||
#FTP_USER=
|
||||
#FTP_PORT=21
|
||||
#REMOTE_PATH=
|
||||
|
||||
## Azure Blob storage
|
||||
#AZURE_STORAGE_CONTAINER_NAME=
|
||||
#AZURE_STORAGE_ACCOUNT_NAME=
|
||||
#AZURE_STORAGE_ACCOUNT_KEY=
|
||||
|
||||
#### Backup encryption
|
||||
#GPG_PUBLIC_KEY=/config/gnupg/public_key.asc
|
||||
#GPG_PRIVATE_KEY=/config/gnupg/private_key.asc
|
||||
#AGE_RECIPIENTS=age1...
|
||||
#AGE_IDENTITY_FILE=/config/age/identity.txt
|
||||
#GPG_PASSPHRASE=Your strong passphrase
|
||||
|
||||
## For multiple database backup on Docker or Docker in Swarm mode
|
||||
#BACKUP_CONFIG_FILE=/config/config.yaml
|
||||
|
||||
### Database restoration
|
||||
#FILE_NAME=
|
||||
|
||||
### Notification
|
||||
#BACKUP_REFERENCE=K8s/Paris cluster
|
||||
|
||||
## Telegram
|
||||
#TG_TOKEN=
|
||||
#TG_CHAT_ID=
|
||||
|
||||
### Email
|
||||
#MAIL_HOST=
|
||||
#MAIL_PORT=
|
||||
#MAIL_USERNAME=
|
||||
#MAIL_PASSWORD=
|
||||
#MAIL_FROM=Backup Jobs <backup-jobs@example.com>
|
||||
#MAIL_TO=backup@example.com,me@example.com,team@example.com
|
||||
#MAIL_TLS=starttls
|
||||
#MAIL_INSECURE_SKIP_VERIFY=false
|
||||
|
||||
## Full reference: mysql-bkup config --env-docs
|
||||
@@ -0,0 +1,10 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: docker
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
- package-ecosystem: gomod
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
@@ -1,7 +1,7 @@
|
||||
name: Build
|
||||
on:
|
||||
push:
|
||||
branches: ['develop']
|
||||
branches: ['nightly']
|
||||
env:
|
||||
BUILDKIT_IMAGE: jkaninda/mysql-bkup
|
||||
jobs:
|
||||
@@ -10,23 +10,25 @@ jobs:
|
||||
steps:
|
||||
-
|
||||
name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
-
|
||||
name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v3
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
push: true
|
||||
file: "./docker/Dockerfile"
|
||||
file: "./Dockerfile"
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
build-args: |
|
||||
appVersion=nightly
|
||||
tags: |
|
||||
"${{env.BUILDKIT_IMAGE}}:develop-${{ github.sha }}"
|
||||
"${{ vars.BUILDKIT_IMAGE || 'jkaninda/mysql-bkup' }}:nightly"
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
@@ -32,14 +32,14 @@ jobs:
|
||||
working-directory: docs
|
||||
- name: Setup Pages
|
||||
id: pages
|
||||
uses: actions/configure-pages@v2
|
||||
uses: actions/configure-pages@v6
|
||||
- name: Build with Jekyll
|
||||
working-directory: docs
|
||||
run: bundle exec jekyll build --baseurl "${{ steps.pages.outputs.base_path }}"
|
||||
env:
|
||||
JEKYLL_ENV: production
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v1
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: 'docs/_site/'
|
||||
|
||||
@@ -52,4 +52,4 @@ jobs:
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v1
|
||||
uses: actions/deploy-pages@v5
|
||||
@@ -0,0 +1,24 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: golangci-lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clone the code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: '1.26'
|
||||
|
||||
- name: Run linter
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: v2.15.0
|
||||
args: --build-tags integration
|
||||
@@ -14,18 +14,18 @@ jobs:
|
||||
steps:
|
||||
-
|
||||
name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
-
|
||||
name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -34,16 +34,29 @@ jobs:
|
||||
name: Get the tag name
|
||||
id: get_tag_name
|
||||
run: echo "TAG_NAME=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
||||
-
|
||||
name: Image tags
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
${{ vars.BUILDKIT_IMAGE || 'jkaninda/mysql-bkup' }}
|
||||
ghcr.io/${{ vars.BUILDKIT_IMAGE || 'jkaninda/mysql-bkup' }}
|
||||
# v2.1.3 -> v2.1.3, 2.1.3, 2.1, 2 and latest (immutable semver tags next to latest)
|
||||
tags: |
|
||||
type=raw,value=${{ env.TAG_NAME }}
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v3
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
push: true
|
||||
file: "./docker/Dockerfile"
|
||||
file: "./Dockerfile"
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: |
|
||||
"${{env.BUILDKIT_IMAGE}}:${{ env.TAG_NAME }}"
|
||||
"${{env.BUILDKIT_IMAGE}}:latest"
|
||||
"ghcr.io/${{env.BUILDKIT_IMAGE}}:${{ env.TAG_NAME }}"
|
||||
"ghcr.io/${{env.BUILDKIT_IMAGE}}:latest"
|
||||
|
||||
build-args: |
|
||||
appVersion=${{ env.TAG_NAME }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -0,0 +1,52 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- nightly
|
||||
- v2
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
unit:
|
||||
name: Vet and unit tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: '1.26'
|
||||
- name: Vet
|
||||
run: |
|
||||
go vet ./...
|
||||
go vet -tags integration ./...
|
||||
- name: Unit tests
|
||||
run: go test -race ./...
|
||||
- name: Env reference is up to date
|
||||
run: go run . config --env-docs > /dev/null
|
||||
|
||||
integration:
|
||||
name: Integration tests (MySQL 8.4 / 8.0, MariaDB 11.8, MinIO)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: '1.26'
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- name: Build the image
|
||||
run: docker buildx build --build-arg appVersion=test -t mysql-bkup:test --load .
|
||||
- name: Image smoke test
|
||||
run: |
|
||||
docker run --rm mysql-bkup:test version
|
||||
docker run --rm --entrypoint mysqldump mysql-bkup:test --version
|
||||
docker run --rm --entrypoint mariadb-dump mysql-bkup:test --version
|
||||
# Miabi deletes local backups with /bin/sh
|
||||
docker run --rm --entrypoint /bin/sh mysql-bkup:test -c "rm -f /backup/x.sql.gz"
|
||||
- name: Integration tests (client tools from the image)
|
||||
env:
|
||||
IT_CLIENT_IMAGE: mysql-bkup:test
|
||||
run: go test -tags integration -count=1 -timeout 40m -v ./...
|
||||
@@ -0,0 +1,55 @@
|
||||
version: "2"
|
||||
run:
|
||||
allow-parallel-runners: true
|
||||
build-tags:
|
||||
- integration
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
- copyloopvar
|
||||
- dupl
|
||||
- errcheck
|
||||
- goconst
|
||||
- gocyclo
|
||||
- govet
|
||||
- ineffassign
|
||||
- lll
|
||||
- misspell
|
||||
- nakedret
|
||||
- prealloc
|
||||
- revive
|
||||
- staticcheck
|
||||
- unconvert
|
||||
- unparam
|
||||
- unused
|
||||
settings:
|
||||
goconst:
|
||||
min-occurrences: 4
|
||||
lll:
|
||||
line-length: 160
|
||||
revive:
|
||||
rules:
|
||||
- name: comment-spacings
|
||||
exclusions:
|
||||
generated: lax
|
||||
rules:
|
||||
- linters:
|
||||
- dupl
|
||||
- lll
|
||||
- goconst
|
||||
- errcheck
|
||||
- prealloc
|
||||
path: _test\.go
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
exclusions:
|
||||
generated: lax
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
ARG GO_IMAGE=golang:1.26.3-trixie
|
||||
ARG RUNTIME_IMAGE=debian:13.7-slim
|
||||
ARG MYSQL_IMAGE=mysql:8.4.13
|
||||
|
||||
FROM --platform=$BUILDPLATFORM ${GO_IMAGE} AS build
|
||||
WORKDIR /app
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
ARG appVersion="development"
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN GOARM="${TARGETVARIANT#v}" CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
|
||||
go build -trimpath -ldflags="-s -w -X main.Version=${appVersion}" -o /out/mysql-bkup .
|
||||
|
||||
# Oracle MySQL client binaries, per architecture.
|
||||
FROM ${MYSQL_IMAGE} AS mysqlclient-amd64
|
||||
RUN mkdir -p /out && cp /usr/bin/mysql /usr/bin/mysqldump /out/
|
||||
FROM ${MYSQL_IMAGE} AS mysqlclient-arm64
|
||||
RUN mkdir -p /out && cp /usr/bin/mysql /usr/bin/mysqldump /out/
|
||||
FROM ${RUNTIME_IMAGE} AS mysqlclient-arm
|
||||
RUN mkdir -p /out
|
||||
FROM mysqlclient-${TARGETARCH} AS mysqlclient
|
||||
|
||||
FROM ${RUNTIME_IMAGE}
|
||||
ARG appVersion="development"
|
||||
ENV TZ=UTC
|
||||
LABEL org.opencontainers.image.title="mysql-bkup" \
|
||||
org.opencontainers.image.description="Back up and restore MySQL and MariaDB databases to local storage, S3, GCS, Azure, SFTP or FTP, with GPG or age encryption" \
|
||||
org.opencontainers.image.licenses="MIT" \
|
||||
org.opencontainers.image.authors="Jonas Kaninda <me@jkaninda.dev>" \
|
||||
org.opencontainers.image.version=${appVersion} \
|
||||
org.opencontainers.image.source="https://github.com/jkaninda/mysql-bkup"
|
||||
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
mariadb-client-core libssl3t64 zlib1g libncurses6 libtinfo6 \
|
||||
tini tzdata ca-certificates \
|
||||
&& cd /tmp && apt-get download mariadb-client \
|
||||
&& dpkg-deb --fsys-tarfile mariadb-client_*.deb | tar -x -C / ./usr/bin/mariadb-dump \
|
||||
&& rm -f mariadb-client_*.deb \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# /usr/local/bin comes first in PATH: mysql and mysqldump are Oracle's there,
|
||||
# while Debian's /usr/bin/mysql* are links to the MariaDB tools.
|
||||
COPY --from=mysqlclient /out/ /usr/local/bin/
|
||||
COPY --from=build /out/mysql-bkup /usr/local/bin/mysql-bkup
|
||||
|
||||
|
||||
RUN mkdir -p /config /backup /tmp/backup \
|
||||
&& chmod 1777 /backup /tmp/backup \
|
||||
&& ln -s /usr/local/bin/mysql-bkup /usr/local/bin/bkup \
|
||||
&& printf '#!/bin/sh\nexec /usr/local/bin/mysql-bkup backup "$@"\n' > /usr/local/bin/backup \
|
||||
&& printf '#!/bin/sh\nexec /usr/local/bin/mysql-bkup restore "$@"\n' > /usr/local/bin/restore \
|
||||
&& printf '#!/bin/sh\nexec /usr/local/bin/mysql-bkup migrate "$@"\n' > /usr/local/bin/migrate \
|
||||
&& chmod +x /usr/local/bin/backup /usr/local/bin/restore /usr/local/bin/migrate \
|
||||
&& (mysqldump --version 2>/dev/null || true) && mariadb-dump --version && mysql-bkup version
|
||||
|
||||
WORKDIR /config
|
||||
HEALTHCHECK --interval=1m --timeout=10s --start-period=1m CMD ["/usr/local/bin/mysql-bkup", "health"]
|
||||
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/mysql-bkup"]
|
||||
@@ -1,174 +1,450 @@
|
||||
# MySQL Backup
|
||||
MySQL Backup is a Docker container image that can be used to backup, restore and migrate MySQL database. It supports local storage, AWS S3 or any S3 Alternatives for Object Storage, and SSH compatible storage.
|
||||
It also supports __encrypting__ your backups using GPG.
|
||||
# MYSQL-BKUP
|
||||
|
||||
The [jkaninda/mysql-bkup](https://hub.docker.com/r/jkaninda/mysql-bkup) Docker image can be deployed on Docker, Docker Swarm and Kubernetes.
|
||||
It handles __recurring__ backups of postgres database on Docker and can be deployed as __CronJob on Kubernetes__ using local, AWS S3 or SSH compatible storage.
|
||||
|
||||
It also supports database __encryption__ using GPG.
|
||||
**MYSQL-BKUP** backs up, restores and migrates **MySQL and MariaDB** databases. It runs as a Docker image (one-shot, as a scheduled daemon, or as a Kubernetes Job/CronJob) and streams every backup end to end: dump, compression, encryption and upload happen in one pipe, without temporary dump files and with a fixed memory footprint, whatever the database size.
|
||||
|
||||
[](https://github.com/jkaninda/mysql-bkup/actions/workflows/tests.yml)
|
||||
[](https://github.com/jkaninda/mysql-bkup/actions/workflows/release.yml)
|
||||
[](https://goreportcard.com/report/github.com/jkaninda/mysql-bkup)
|
||||

|
||||

|
||||
<a href="https://ko-fi.com/jkaninda"><img src="https://uploads-ssl.webflow.com/5c14e387dab576fe667689cf/5cbed8a4ae2b88347c06c923_BuyMeACoffee_blue.png" height="20" alt="buy ma a coffee"></a>
|
||||
|
||||
Successfully tested on:
|
||||
- Docker
|
||||
- Docker in Swarm mode
|
||||
- Kubernetes
|
||||
- OpenShift
|
||||
Documentation: <https://jkaninda.github.io/mysql-bkup>
|
||||
|
||||
## Documentation is found at <https://jkaninda.github.io/mysql-bkup>
|
||||
## Features
|
||||
|
||||
- **MySQL and MariaDB, each with its own tools.** The server flavour is detected (`SELECT VERSION()`): MariaDB is dumped with `mariadb-dump` and restored with `mariadb`, MySQL 8+ with Oracle's `mysqldump` and `mysql` (8.4 LTS client). Tested against MySQL 8.0, MySQL 8.4 and MariaDB 11.8.
|
||||
- **Storage:** local, Amazon S3 and S3-compatible (MinIO, Wasabi, R2, ...), Google Cloud Storage, Azure Blob, SFTP, FTP/FTPS. Extra copies to more storages from the config file.
|
||||
- **Encryption:** GPG (passphrase or public key, the default and compatible with 1.x) or [age](https://age-encryption.org) (recipients or passphrase). Encryption fails closed: a configured key that cannot be loaded is an error, never a silent plaintext upload.
|
||||
- **Compression:** gzip (default, parallel) or zstd; restore detects the format.
|
||||
- **Backup index and safe retention:** every storage folder has an index (`mysql-bkup-metadata.json`) and a `.meta.json` sidecar per backup with its sha256. Retention by age and/or count only deletes backups the index knows, and always keeps the newest one.
|
||||
- **Commands:** `backup`, `restore`, `migrate`, `verify`, `list`, `delete`, `prune`, `reconcile`, `config`, `health`.
|
||||
- **Scheduling:** cron expressions (5 or 6 fields, `@daily`, `@every 6h`), no overlapping runs, clean shutdown on SIGTERM, Prometheus metrics, Docker `HEALTHCHECK`.
|
||||
- **Notifications:** email, Telegram, Slack, Discord, ntfy, generic webhooks (HMAC-signed), healthchecks.io-style pings.
|
||||
- **Security:** no shell anywhere; database passwords go to the client tools through a 0600 option file in a private directory (never on the command line), and every secret can be read from `<NAME>_FILE` (Docker/Kubernetes secrets).
|
||||
- **Multi-arch image:** `amd64`, `arm64`, `arm/v7` (see [Client tools](#client-tools)).
|
||||
|
||||
## Links:
|
||||
## Quick start
|
||||
|
||||
- [Docker Hub](https://hub.docker.com/r/jkaninda/mysql-bkup)
|
||||
- [Github](https://github.com/jkaninda/mysql-bkup)
|
||||
|
||||
## PostgreSQL solution :
|
||||
|
||||
- [PostgreSQL](https://github.com/jkaninda/pg-bkup)
|
||||
|
||||
## Storage:
|
||||
- Local
|
||||
- AWS S3 or any S3 Alternatives for Object Storage
|
||||
- SSH remote server
|
||||
|
||||
## Quickstart
|
||||
|
||||
### Simple backup using Docker CLI
|
||||
|
||||
To run a one time backup, bind your local volume to `/backup` in the container and run the `backup` command:
|
||||
Back up the database `shop` into `./backup`:
|
||||
|
||||
```shell
|
||||
docker run --rm --network your_network_name \
|
||||
-v $PWD/backup:/backup/ \
|
||||
-e "DB_HOST=dbhost" \
|
||||
-e "DB_USERNAME=username" \
|
||||
-e "DB_PASSWORD=password" \
|
||||
jkaninda/mysql-bkup backup -d database_name
|
||||
docker run --rm --network your_network \
|
||||
-v $PWD/backup:/backup \
|
||||
-e DB_HOST=mysql -e DB_USER=root -e DB_PASSWORD=password \
|
||||
jkaninda/mysql-bkup backup -d shop
|
||||
```
|
||||
|
||||
Alternatively, pass a `--env-file` in order to use a full config as described below.
|
||||
The last line of the output names the backup:
|
||||
|
||||
```yaml
|
||||
docker run --rm --network your_network_name \
|
||||
--env-file your-env-file \
|
||||
-v $PWD/backup:/backup/ \
|
||||
jkaninda/mysql-bkup backup -d database_name
|
||||
```
|
||||
level=INFO msg="Backup completed" database=shop file=shop_20261027_031500.sql.gz size="1.2 MiB" duration=2.1s storage=local location=/backup
|
||||
```
|
||||
|
||||
### Simple backup in docker compose file
|
||||
Restore it:
|
||||
|
||||
```shell
|
||||
docker run --rm --network your_network \
|
||||
-v $PWD/backup:/backup \
|
||||
-e DB_HOST=mysql -e DB_USER=root -e DB_PASSWORD=password \
|
||||
jkaninda/mysql-bkup restore -d shop -f shop_20261027_031500.sql.gz
|
||||
```
|
||||
|
||||
Restore accepts mysql-bkup backups and any plain `.sql` or gzip `.sql.gz` dump (encrypted or not). A missing database is created first.
|
||||
|
||||
### Docker Compose, scheduled
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mysql-bkup:
|
||||
# In production, it is advised to lock your image tag to a proper
|
||||
# release version instead of using `latest`.
|
||||
# Check https://github.com/jkaninda/mysql-bkup/releases
|
||||
# for a list of available releases.
|
||||
image: jkaninda/mysql-bkup
|
||||
container_name: mysql-bkup
|
||||
command: backup
|
||||
command: backup -d shop --cron-expression "@daily"
|
||||
volumes:
|
||||
- ./backup:/backup
|
||||
environment:
|
||||
- DB_PORT=3306
|
||||
- DB_HOST=mysql
|
||||
- DB_NAME=foo
|
||||
- DB_USERNAME=bar
|
||||
- DB_PASSWORD=password
|
||||
# mysql-bkup container must be connected to the same network with your database
|
||||
networks:
|
||||
- web
|
||||
networks:
|
||||
web:
|
||||
- DB_USER=backup
|
||||
- DB_PASSWORD_FILE=/run/secrets/db_password
|
||||
- BACKUP_RETENTION_DAYS=7
|
||||
- GPG_PASSPHRASE=change-me
|
||||
```
|
||||
## Deploy on Kubernetes
|
||||
|
||||
For Kubernetes, you don't need to run it in scheduled mode. You can deploy it as Job or CronJob.
|
||||
|
||||
### Simple Kubernetes backup Job :
|
||||
### Kubernetes CronJob
|
||||
|
||||
```yaml
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: backup-job
|
||||
name: mysql-bkup
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 100
|
||||
template:
|
||||
schedule: "0 1 * * *"
|
||||
jobTemplate:
|
||||
spec:
|
||||
containers:
|
||||
- name: pg-bkup
|
||||
# In production, it is advised to lock your image tag to a proper
|
||||
# release version instead of using `latest`.
|
||||
# Check https://github.com/jkaninda/mysql-bkup/releases
|
||||
# for a list of available releases.
|
||||
image: jkaninda/mysql-bkup
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- backup -d dbname
|
||||
resources:
|
||||
limits:
|
||||
memory: "128Mi"
|
||||
cpu: "500m"
|
||||
env:
|
||||
- name: DB_HOST
|
||||
value: "mysql"
|
||||
- name: DB_USERNAME
|
||||
value: "user"
|
||||
- name: DB_PASSWORD
|
||||
value: "password"
|
||||
volumeMounts:
|
||||
- mountPath: /backup
|
||||
name: backup
|
||||
volumes:
|
||||
- name: backup
|
||||
hostPath:
|
||||
path: /home/toto/backup # directory location on host
|
||||
type: Directory # this field is optional
|
||||
restartPolicy: Never
|
||||
template:
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
containers:
|
||||
- name: mysql-bkup
|
||||
image: jkaninda/mysql-bkup
|
||||
args: ["backup", "-d", "shop", "--storage", "s3", "--path", "/mysql"]
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: mysql-bkup # DB_HOST, DB_USER, DB_PASSWORD, AWS_* ...
|
||||
```
|
||||
## Available image registries
|
||||
|
||||
This Docker image is published to both Docker Hub and the GitHub container registry.
|
||||
Depending on your preferences and needs, you can reference both `jkaninda/mysql-bkup` as well as `ghcr.io/jkaninda/mysql-bkup`:
|
||||
## Commands
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `backup` | Back up now, or as a daemon with `--cron-expression` / `BACKUP_CRON_EXPRESSION`. `-d DB`, `-a` (every database, one file each), `-A` (every database in one file), `--exclude-db`, `--custom-name`, `--disable-compression`, `-c config.yaml` |
|
||||
| `restore` | Stream a backup back: `-f FILE [-d DB]`. Without `-d`, the dump's own `USE` statements decide (all-in-one backups) |
|
||||
| `migrate` | Copy `DB_NAME` (or every database with `-a`) straight into `TARGET_DB_*`: `dump \| restore` through a pipe, no file |
|
||||
| `verify` | Download, decrypt and decompress a backup and check its sha256, without restoring |
|
||||
| `list` | Backup history from the index (`--json`, `--storage-scan`) |
|
||||
| `delete` | Delete a backup, its sidecar and its index entry |
|
||||
| `prune` | Apply the retention policy (`--dry-run`) |
|
||||
| `reconcile` | Rebuild the index from the sidecars (`--from-index OLD` after renaming it) |
|
||||
| `config` | Show the resolved configuration with sources, secrets redacted; `--env-docs` prints the reference below |
|
||||
| `health` | Docker `HEALTHCHECK` for the daemon |
|
||||
| `version` | Print the version |
|
||||
|
||||
The image also has the `backup`, `restore` and `migrate` shortcuts (`docker run ... --entrypoint backup`, or in Kubernetes `command: [backup]`) and the `bkup` alias.
|
||||
|
||||
### Exit codes
|
||||
|
||||
`0` success, `1` failure, `2` configuration error, `3` database connection or authentication, `4` storage, `5` encryption/decryption.
|
||||
|
||||
## Storage
|
||||
|
||||
```shell
|
||||
# S3 / MinIO
|
||||
-e AWS_S3_ENDPOINT=https://s3.amazonaws.com -e AWS_S3_BUCKET_NAME=backups -e AWS_REGION=us-east-1 \
|
||||
-e AWS_ACCESS_KEY=... -e AWS_SECRET_KEY=... \
|
||||
jkaninda/mysql-bkup backup -d shop --storage s3 --path /mysql
|
||||
# MinIO and other S3 alternatives: add AWS_FORCE_PATH_STYLE=true (and AWS_DISABLE_SSL=true for plain http).
|
||||
# Without AWS_ACCESS_KEY, the AWS default credential chain is used (IAM role, IRSA, profile).
|
||||
|
||||
# SFTP (host keys are verified: mount a known_hosts file)
|
||||
-e SFTP_HOST=backup.example.com -e SFTP_USER=backup -e SFTP_IDENTITY_FILE=/config/id_ed25519 \
|
||||
-e SFTP_KNOWN_HOSTS=/config/known_hosts \
|
||||
jkaninda/mysql-bkup backup -d shop --storage sftp --path /home/backup/mysql
|
||||
|
||||
# FTP / FTPS
|
||||
-e FTP_HOST=ftp.example.com -e FTP_USER=backup -e FTP_PASSWORD=... -e FTP_TLS=true \
|
||||
jkaninda/mysql-bkup backup -d shop --storage ftp --path /mysql
|
||||
|
||||
# Azure Blob
|
||||
-e AZURE_STORAGE_ACCOUNT_NAME=... -e AZURE_STORAGE_ACCOUNT_KEY=... -e AZURE_STORAGE_CONTAINER_NAME=backups \
|
||||
jkaninda/mysql-bkup backup -d shop --storage azure --path mysql
|
||||
|
||||
# Google Cloud Storage
|
||||
-e GCS_BUCKET=backups -e GCS_CREDENTIALS_FILE=/config/gcs.json \
|
||||
jkaninda/mysql-bkup backup -d shop --storage gcs --path mysql
|
||||
```
|
||||
|
||||
`STORAGE` (env) and `--storage` are equivalent; `ssh` and `remote` are accepted for `sftp`. The object key is exactly `<path>/<file>`.
|
||||
|
||||
## Encryption
|
||||
|
||||
| Mode | Backup | Restore | File |
|
||||
|---|---|---|---|
|
||||
| GPG passphrase | `GPG_PASSPHRASE` | `GPG_PASSPHRASE` | `.sql.gz.gpg` |
|
||||
| GPG public key | `GPG_PUBLIC_KEY` (path or inline), or `/config/gnupg/public_key.asc` | `GPG_PRIVATE_KEY` (+ `GPG_PRIVATE_KEY_PASSPHRASE`) | `.sql.gz.gpg` |
|
||||
| age recipients | `AGE_RECIPIENTS` (`age1...`, `ssh-ed25519 ...`) or `AGE_RECIPIENTS_FILE` | `AGE_IDENTITY` / `AGE_IDENTITY_FILE` (default `/config/age/identity.txt`) | `.sql.gz.age` |
|
||||
| age passphrase | `AGE_PASSPHRASE` | `AGE_PASSPHRASE` | `.sql.gz.age` |
|
||||
|
||||
`ENCRYPTION=auto` (default) picks the mode from the keys that are set; GPG wins when both GPG and age keys are set. Restore detects the format from the content.
|
||||
|
||||
## Backup index and retention
|
||||
|
||||
Each storage folder holds an index file (`BACKUP_INDEX_NAME`, default `{tool}-metadata.json`; placeholders `{tool}`, `{engine}` (always `mysql`), `{name}`, `{db}`) and one `<backup>.meta.json` sidecar per backup. `list`, `delete`, `prune` and retention work from the index, so mysql-bkup only ever deletes files it created itself, and several instances can share a folder with different index names.
|
||||
|
||||
- `BACKUP_RETENTION_DAYS=N` deletes backups older than N days, `BACKUP_RETENTION_COUNT=N` keeps the newest N. The newest successful backup is always kept.
|
||||
- Backups made before the index existed (1.x) are never deleted, unless `BACKUP_RETENTION_ADOPT_LEGACY=true` imports them.
|
||||
- `BACKUP_INDEX=false` turns the index off; retention then only matches this tool's file names directly in the folder.
|
||||
|
||||
## Multiple databases
|
||||
|
||||
```shell
|
||||
backup -a # every database, one file each (system databases are skipped)
|
||||
backup -a --exclude-db test,tmp
|
||||
backup -A # everything in one file: all_databases_<timestamp>.sql.gz
|
||||
```
|
||||
|
||||
Or a YAML file (`-c /config/config.yaml`, also picked up automatically at `/config/config.yaml`); the 1.x format still works:
|
||||
|
||||
```yaml
|
||||
cronExpression: "@daily"
|
||||
databases:
|
||||
- name: shop
|
||||
host: mysql1
|
||||
user: backup
|
||||
password: ${SHOP_PASSWORD}
|
||||
path: /shop
|
||||
- name: blog # credentials from DB_USER_BLOG / BLOG_DB_USER ... or the global DB_*
|
||||
```
|
||||
|
||||
## Client tools
|
||||
|
||||
| Server | Dump | Restore |
|
||||
|---|---|---|
|
||||
| MariaDB | `mariadb-dump` (MariaDB 11.8 LTS client) | `mariadb` |
|
||||
| MySQL 8.0 / 8.4 / 9.x | Oracle `mysqldump` 8.4 LTS | Oracle `mysql` 8.4 LTS |
|
||||
|
||||
Dumps use `--single-transaction --routines --triggers --events --hex-blob --quick`, plus `--no-tablespaces --set-gtid-purged=OFF` on MySQL. A single-database dump has no `CREATE DATABASE`/`USE`, so it can be restored under another name.
|
||||
|
||||
Restores into a named database (`-d`) also accept dumps written elsewhere: the `CREATE DATABASE`, `DROP DATABASE` and `USE` statements of a `mysqldump --databases` dump are skipped, so its objects land in the target database whatever the source was called (a dump of several databases is refused). `DEFINER` clauses are removed when the restoring user may not name another definer (`RESTORE_NO_DEFINER=auto`): views, triggers, routines and events then belong to that user. Dumps from MariaDB and from older MySQL versions restore into MySQL 8: the MariaDB sandbox line is dropped for the Oracle client, MariaDB's `utf8mb4_uca1400_ai_ci` (`_as_ci`, `_as_cs`) collations become `utf8mb4_0900_*`, and `sql_mode` values keep only the modes MySQL 8 knows (`NO_AUTO_CREATE_USER` goes). Data is never rewritten. On MySQL with binary logging on (the default), creating functions and triggers without `SUPER` also needs the server setting `log_bin_trust_function_creators=1`.
|
||||
|
||||
The image is based on Debian 13 (trixie) slim. Oracle publishes its client for `amd64` and `arm64` only: on `arm/v7` the image has the MariaDB tools only, and MySQL servers are dumped with `mariadb-dump` (a warning is logged). `MYSQL_CLIENT=mysql|mariadb` forces one family.
|
||||
|
||||
TLS: `DB_SSL_MODE=disabled` (default, as in 1.x) `| preferred | required | verify_ca | verify_identity`, with `DB_SSL_CA` (used for the verify modes), `DB_SSL_CERT`, `DB_SSL_KEY`. 1.x values `0` and `1` still mean disabled and required. With the MariaDB client, `verify_ca` also checks the host name.
|
||||
|
||||
Restores are not atomic: MySQL cannot roll back DDL, so a restore that fails halfway leaves the statements before the failure applied (the exit code is non-zero). Restore into a new database, or keep a backup of the target, when that matters.
|
||||
|
||||
## Use with Miabi
|
||||
|
||||
Miabi runs `backup -d <db>`, `backup --storage s3 -d <db> --path <path>` and `restore -d <db> -f <file> [--storage s3 --path <path>]` with `DB_*`, `AWS_*` and `GPG_PASSPHRASE`, and reads the artifact name from the last `file=` in the output. That contract is unchanged and covered by the integration tests.
|
||||
|
||||
## Upgrading from 1.x
|
||||
|
||||
Everything that worked in 1.x keeps working; a few behaviours changed on purpose.
|
||||
|
||||
- **Image:** Debian 13 slim instead of Alpine, with Oracle's `mysqldump` for MySQL servers (correct MySQL 8 dumps) and `mariadb-dump` for MariaDB. `/bin/sh`, `/backup`, `/config`, the `backup`/`restore`/`migrate` shortcuts and the `bkup` alias are still there; `tini` is now PID 1.
|
||||
- **Renamed variables** are still read, with a deprecation warning: `DB_USER` → `DB_USER`, `S3_ENDPOINT` → `AWS_S3_ENDPOINT`, `BUCKET_NAME`/`AWS_BUCKET_NAME` → `AWS_S3_BUCKET_NAME`, `ACCESS_KEY`/`SECRET_KEY` → `AWS_ACCESS_KEY`/`AWS_SECRET_KEY`, `AWS_S3_PATH`/`S3_PATH`/`REMOTE_PATH` → `REMOTE_PATH`, `SFTP_HOST` → `SFTP_HOST`, `SFTP_IDENTITY_FILE` → `SFTP_IDENTITY_FILE`, `FTP_HOST_NAME` → `FTP_HOST`, `BACKUP_TMP_DIR` → `TMP_DIR`.
|
||||
- **`DB_SSL_MODE`** takes MySQL's mode names; `0`/`1` still work.
|
||||
- **`STORAGE` and `STORAGE_PATH` env are honoured** (1.x always used the `--storage` default and `/backup`). Unknown storage names are an error instead of a silent local backup.
|
||||
- **SFTP variables are renamed, and the old names are no longer read:** `SSH_HOST`/`SSH_HOST_NAME` → `SFTP_HOST`, `SSH_PORT` → `SFTP_PORT`, `SSH_USER` → `SFTP_USER`, `SSH_PASSWORD` → `SFTP_PASSWORD`, `SSH_IDENTIFY_FILE`/`SSH_IDENTITY_FILE` → `SFTP_IDENTITY_FILE`, `SSH_REMOTE_PATH` → `REMOTE_PATH`.
|
||||
- **SFTP host keys are verified by default.** Set `SFTP_KNOWN_HOSTS=/path/known_hosts`, or `SFTP_INSECURE_IGNORE_HOST_KEY=true` to keep the 1.x behaviour.
|
||||
- **`MAIL_SKIP_TLS` is read with its documented meaning** (`true` skips certificate verification). 1.x had it inverted, so `MAIL_SKIP_TLS=false` (the value in `.env.example`) turned verification *off*. Use `MAIL_INSECURE_SKIP_VERIFY` from now on.
|
||||
- **Retention only deletes indexed backups** (see above). 1.x deleted every file older than N days in the folder. Set `BACKUP_RETENTION_ADOPT_LEGACY=true` once to let retention handle 1.x backups, or `BACKUP_INDEX=false` for name-based retention.
|
||||
- **Encryption fails closed**: a `GPG_PUBLIC_KEY` that cannot be read is an error (1.x uploaded plaintext). The env var now wins over the auto-detected `/config/gnupg` key.
|
||||
- **Errors no longer kill the daemon**: a failed run is logged, notified and retried at the next schedule; other databases of the same run continue (`BACKUP_RESCUE_MODE` is no longer needed).
|
||||
- **Restore** works from every storage (1.x had no SFTP restore), is case-insensitive about the storage name, and no longer goes through `sh -c`.
|
||||
- **`migrate`** streams directly into the target (no intermediate file), creates the target database if needed, and `TARGET_DB_NAME` defaults to the source name; `-a` migrates every database.
|
||||
- **Notification templates** are built in; custom ones still go in `/config/templates` (`TEMPLATES_DIR`).
|
||||
- **Version** is injected with `-ldflags "-X main.Version=..."` (was `utils.Version`).
|
||||
|
||||
## Building
|
||||
|
||||
```shell
|
||||
make build # bin/mysql-bkup
|
||||
make test # unit tests
|
||||
make test-integration # Docker: MySQL 8.4/8.0, MariaDB 11.8, MinIO
|
||||
make docker-build VERSION=v2.0.0
|
||||
make env-docs # regenerate the reference below
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
Generated with `mysql-bkup config --env-docs`. Every secret also accepts `<NAME>_FILE`. Resolution order: flag, then env, then deprecated names, then the YAML `env:` section, then the default.
|
||||
|
||||
### storage
|
||||
|
||||
| Variable | Flag | Default | Fallbacks | Description |
|
||||
|---|---|---|---|---|
|
||||
| `STORAGE` | `--storage` `-s` | `local` | | Storage backend: local, s3, gcs, azure, sftp (ssh, remote), ftp (local, s3, gcs, azure, sftp, ssh, ftp, remote) |
|
||||
| `STORAGE_PATH` | | `/backup` | | Root directory of the local storage backend |
|
||||
| `REMOTE_PATH` | `--path` `-P` | | `AWS_S3_PATH` (deprecated), `S3_PATH` (deprecated) | Folder inside the storage backend (without file name) |
|
||||
|
||||
### general
|
||||
|
||||
| Variable | Flag | Default | Fallbacks | Description |
|
||||
|---|---|---|---|---|
|
||||
| `BACKUP_NAME` | `--name` `-n` | | | Base name of the artifact; a timestamp is appended (default: the database or volume name) |
|
||||
| `BACKUP_CUSTOM_NAME` | `--custom-name` | | | Fixed artifact name without timestamp (overwrites the previous one) |
|
||||
| `FILE_NAME` | `--file` `-f` | | | Backup file to restore, verify or delete |
|
||||
| `BACKUP_CONFIG_FILE` | `--config` `-c` | | | YAML configuration file (default /config/config.yaml if present) |
|
||||
| `BACKUP_TIMEOUT` | | | | Abort a backup after this duration (0 = none) |
|
||||
| `RESTORE_TIMEOUT` | | | | Abort a restore after this duration (0 = none) |
|
||||
| `TMP_DIR` | | `/tmp/backup` | `BACKUP_TMP_DIR` (deprecated) | Scratch directory (only for secret files and disk-based modes) |
|
||||
| `COMPRESSION` | `--compression` | `gzip` | | Compression: gzip, zstd or none (gzip, zstd, none) |
|
||||
| `COMPRESSION_LEVEL` | | | | Compression level (0 = default) |
|
||||
| `DISABLE_COMPRESSION` | `--disable-compression` | | | Same as COMPRESSION=none |
|
||||
| `RESTORE_VERIFY_FIRST` | `--verify-first` | `auto` | | Verify the whole artifact before restoring (auto: on for GPG-encrypted volumes) (auto, true, false) |
|
||||
| `LOG_LEVEL` | | `info` | | Log level (debug, info, warn, error) |
|
||||
| `LOG_FORMAT` | | `text` | | Log format (text, json) |
|
||||
| `TIME_FORMAT` | | `2006-01-02 15:04:05` | | Time layout used in notifications |
|
||||
| `RESULT_FILE` | `--result-file` | | | Write the run result as JSON to this path |
|
||||
|
||||
### scheduling
|
||||
|
||||
| Variable | Flag | Default | Fallbacks | Description |
|
||||
|---|---|---|---|---|
|
||||
| `BACKUP_CRON_EXPRESSION` | `--cron-expression` `-e` | | | Run as a daemon on this cron schedule (5 or 6 fields, @daily, @every 6h) |
|
||||
| `BACKUP_RUN_ON_START` | | | | In daemon mode, run one backup immediately at start |
|
||||
| `BACKUP_RESCUE_MODE` | | | | Deprecated: runs always continue with the remaining targets after a failure |
|
||||
| `METRICS_ADDR` | | | | Serve Prometheus /metrics and /healthz on this address in daemon mode (e.g. :9090) |
|
||||
| `HEALTH_FILE` | | `/tmp/bkup-health.json` | | State file read by the health command |
|
||||
| `HEALTH_MAX_AGE` | | | | health fails when the last success is older than this (default: 2x the schedule interval) |
|
||||
|
||||
### retention
|
||||
|
||||
| Variable | Flag | Default | Fallbacks | Description |
|
||||
|---|---|---|---|---|
|
||||
| `BACKUP_RETENTION_DAYS` | | `0` | | Delete backups older than N days (0 = keep) |
|
||||
| `BACKUP_RETENTION_COUNT` | | `0` | | Keep only the newest N backups (0 = no limit) |
|
||||
| `BACKUP_RETENTION_ALLOW_ROOT` | | | | Allow pattern-based retention at the storage root (index disabled) |
|
||||
| `BACKUP_RETENTION_ADOPT_LEGACY` | | | | Import pre-index backups matching this tool's naming into the index |
|
||||
| `BACKUP_INDEX` | | `true` | | Keep the backup index file and per-backup .meta.json sidecars |
|
||||
| `BACKUP_INDEX_NAME` | `--index-name` | `{tool}-metadata.json` | | Index file name; placeholders {tool} {engine} {name} {db} |
|
||||
|
||||
### storage-s3
|
||||
|
||||
| Variable | Flag | Default | Fallbacks | Description |
|
||||
|---|---|---|---|---|
|
||||
| `AWS_S3_ENDPOINT` | | | `S3_ENDPOINT` (deprecated), `AWS_ENDPOINT_URL_S3`, `AWS_ENDPOINT_URL` | S3 endpoint (empty = AWS) |
|
||||
| `AWS_S3_BUCKET_NAME` | | | `BUCKET_NAME` (deprecated), `AWS_BUCKET_NAME` (deprecated) | S3 bucket |
|
||||
| `AWS_REGION` | | `us-east-1` | `AWS_DEFAULT_REGION` | S3 region |
|
||||
| `AWS_ACCESS_KEY` | | | `ACCESS_KEY` (deprecated), `AWS_ACCESS_KEY_ID` | S3 access key (empty = default AWS credential chain) |
|
||||
| `AWS_SECRET_KEY` | | | `SECRET_KEY` (deprecated), `AWS_SECRET_ACCESS_KEY`, `AWS_SECRET_KEY_FILE` | S3 secret key |
|
||||
| `AWS_SESSION_TOKEN` | | | `AWS_SESSION_TOKEN_FILE` | S3 session token |
|
||||
| `AWS_PROFILE` | | | | AWS shared config profile |
|
||||
| `AWS_DISABLE_SSL` | | | | Use http for the S3 endpoint |
|
||||
| `AWS_FORCE_PATH_STYLE` | | | | Path-style S3 addressing (MinIO) |
|
||||
| `S3_STORAGE_CLASS` | | | | S3 storage class |
|
||||
| `S3_SSE` | | | | Server-side encryption: AES256 or aws:kms (aes256, aws:kms) |
|
||||
| `S3_SSE_KMS_KEY_ID` | | | | KMS key id for aws:kms |
|
||||
| `S3_PART_SIZE` | | `64MiB` | | Multipart part size; max object size is 10000 x this |
|
||||
| `S3_UPLOAD_CONCURRENCY` | | `2` | | Parallel part uploads (memory = part size x this) |
|
||||
|
||||
### storage-sftp
|
||||
|
||||
| Variable | Flag | Default | Fallbacks | Description |
|
||||
|---|---|---|---|---|
|
||||
| `SFTP_HOST` | | | | SFTP host |
|
||||
| `SFTP_PORT` | | `22` | | SFTP port |
|
||||
| `SFTP_USER` | | | | SFTP user |
|
||||
| `SFTP_PASSWORD` | | | `SFTP_PASSWORD_FILE` | SFTP password |
|
||||
| `SFTP_IDENTITY_FILE` | | | | SFTP private key file (the SSH agent is also used when SSH_AUTH_SOCK is set) |
|
||||
| `SFTP_IDENTITY_PASSPHRASE` | | | `SFTP_IDENTITY_PASSPHRASE_FILE` | Passphrase of the SFTP private key |
|
||||
| `SFTP_KNOWN_HOSTS` | | | | known_hosts file used to verify the SFTP host key (default ~/.ssh/known_hosts) |
|
||||
| `SFTP_INSECURE_IGNORE_HOST_KEY` | | | | Do not verify the SFTP host key (insecure) |
|
||||
|
||||
### storage-ftp
|
||||
|
||||
| Variable | Flag | Default | Fallbacks | Description |
|
||||
|---|---|---|---|---|
|
||||
| `FTP_HOST` | | | `FTP_HOST_NAME` (deprecated) | FTP host |
|
||||
| `FTP_PORT` | | `21` | | FTP port |
|
||||
| `FTP_USER` | | | | FTP user |
|
||||
| `FTP_PASSWORD` | | | `FTP_PASSWORD_FILE` | FTP password |
|
||||
| `FTP_TLS` | | | | Use explicit FTPS |
|
||||
|
||||
### storage-azure
|
||||
|
||||
| Variable | Flag | Default | Fallbacks | Description |
|
||||
|---|---|---|---|---|
|
||||
| `AZURE_STORAGE_ACCOUNT_NAME` | | | | Azure storage account |
|
||||
| `AZURE_STORAGE_ACCOUNT_KEY` | | | `AZURE_STORAGE_ACCOUNT_KEY_FILE` | Azure storage account key |
|
||||
| `AZURE_STORAGE_CONTAINER_NAME` | | | | Azure blob container |
|
||||
| `AZURE_STORAGE_ENDPOINT` | | | | Azure blob endpoint (Azurite, sovereign clouds) |
|
||||
| `AZURE_STORAGE_SAS_TOKEN` | | | `AZURE_STORAGE_SAS_TOKEN_FILE` | Azure SAS token (instead of the account key) |
|
||||
|
||||
### storage-gcs
|
||||
|
||||
| Variable | Flag | Default | Fallbacks | Description |
|
||||
|---|---|---|---|---|
|
||||
| `GCS_BUCKET` | | | | GCS bucket |
|
||||
| `GCS_CREDENTIALS_FILE` | | | `GOOGLE_APPLICATION_CREDENTIALS` | GCS service account JSON file |
|
||||
| `GCS_CREDENTIALS_JSON` | | | `GCS_CREDENTIALS_JSON_FILE` | GCS service account JSON content |
|
||||
| `GCS_ENDPOINT` | | | | GCS endpoint (emulators) |
|
||||
|
||||
### encryption
|
||||
|
||||
| Variable | Flag | Default | Fallbacks | Description |
|
||||
|---|---|---|---|---|
|
||||
| `ENCRYPTION` | `--encryption` | `auto` | | auto (from the keys set), none, gpg or age (auto, none, gpg, age) |
|
||||
| `ENCRYPTION_ARMOR` | | | | ASCII-armor encrypted output |
|
||||
| `GPG_PASSPHRASE` | | | `GPG_PASSPHRASE_FILE` | GPG symmetric passphrase |
|
||||
| `GPG_PUBLIC_KEY` | | | | GPG public key(s): file path or inline key (default /config/gnupg/public_key.{asc,gpg}) |
|
||||
| `GPG_PRIVATE_KEY` | | | `GPG_PRIVATE_KEY_FILE` | GPG private key for restore: file path or inline key (default /config/gnupg/private_key.{asc,gpg}) |
|
||||
| `GPG_PRIVATE_KEY_PASSPHRASE` | | | `GPG_PRIVATE_KEY_PASSPHRASE_FILE` | Passphrase of the GPG private key (default GPG_PASSPHRASE) |
|
||||
| `AGE_RECIPIENTS` | | | | age recipients (age1..., ssh-ed25519 ...), comma or newline separated |
|
||||
| `AGE_RECIPIENTS_FILE` | | | | File with age recipients |
|
||||
| `AGE_PASSPHRASE` | | | `AGE_PASSPHRASE_FILE` | age passphrase (scrypt) |
|
||||
| `AGE_IDENTITY` | | | `AGE_IDENTITY_FILE` | age identity (AGE-SECRET-KEY-...) for restore |
|
||||
| `AGE_IDENTITY_FILE` | | | | age identity file for restore (default /config/age/identity.txt) |
|
||||
|
||||
### notifications
|
||||
|
||||
| Variable | Flag | Default | Fallbacks | Description |
|
||||
|---|---|---|---|---|
|
||||
| `MAIL_HOST` | | | | SMTP host |
|
||||
| `MAIL_PORT` | | `587` | | SMTP port |
|
||||
| `MAIL_USERNAME` | | | | SMTP user |
|
||||
| `MAIL_PASSWORD` | | | `MAIL_PASSWORD_FILE` | SMTP password |
|
||||
| `MAIL_FROM` | | | | Sender address |
|
||||
| `MAIL_TO` | | | | Recipients, comma separated |
|
||||
| `MAIL_SKIP_TLS` | | | | Deprecated: true skips certificate verification (use MAIL_INSECURE_SKIP_VERIFY) |
|
||||
| `MAIL_INSECURE_SKIP_VERIFY` | | | | Skip SMTP certificate verification |
|
||||
| `MAIL_TLS` | | `starttls` | | SMTP TLS mode (starttls, tls, none) |
|
||||
| `TG_TOKEN` | | | `TELEGRAM_BOT_TOKEN`, `TG_TOKEN_FILE` | Telegram bot token |
|
||||
| `TG_CHAT_ID` | | | `TELEGRAM_CHAT_ID` | Telegram chat id |
|
||||
| `POSTA_URL` | | | | Posta base URL (https://posta.example.com) for email notifications through Posta |
|
||||
| `POSTA_API_KEY` | | | `POSTA_API_KEY_FILE` | Posta API key with the send scope |
|
||||
| `POSTA_WORKSPACE_ID` | | | | Posta workspace id (only for account-wide API keys) |
|
||||
| `POSTA_FROM` | | | | Posta sender address (default MAIL_FROM) |
|
||||
| `POSTA_TO` | | | | Posta recipients, comma separated (default MAIL_TO) |
|
||||
| `POSTA_TEMPLATE` | | | | Posta template name for notifications; the event fields are passed as template data (default: built-in HTML) |
|
||||
| `POSTA_TEMPLATE_ERROR` | | | | Posta template name for failures (default POSTA_TEMPLATE) |
|
||||
| `POSTA_LANGUAGE` | | | | Posta template language |
|
||||
| `WEBHOOK_URL` | | | | Generic JSON webhook URL |
|
||||
| `WEBHOOK_SECRET` | | | `WEBHOOK_SECRET_FILE` | HMAC-SHA256 secret for the X-Bkup-Signature header |
|
||||
| `SLACK_WEBHOOK_URL` | | | `SLACK_WEBHOOK_URL_FILE` | Slack incoming webhook URL |
|
||||
| `DISCORD_WEBHOOK_URL` | | | `DISCORD_WEBHOOK_URL_FILE` | Discord webhook URL |
|
||||
| `NTFY_URL` | | | | ntfy topic URL |
|
||||
| `NTFY_TOKEN` | | | `NTFY_TOKEN_FILE` | ntfy access token |
|
||||
| `NOTIFY_ON` | | `always` | | When to notify (always, failure, success, never) |
|
||||
| `HEALTHCHECK_PING_URL` | | | | healthchecks.io-style URL pinged on start (/start), success and failure (/fail) |
|
||||
| `BACKUP_REFERENCE` | | | | Free text included in notifications |
|
||||
| `TEMPLATES_DIR` | | `/config/templates` | | Directory with custom notification templates |
|
||||
|
||||
### database
|
||||
|
||||
| Variable | Flag | Default | Fallbacks | Description |
|
||||
|---|---|---|---|---|
|
||||
| `DB_URL` | | | `DB_URL_FILE` | Connection URL mysql://user:password@host:port/database?ssl-mode=required (overrides host, port, user, password) |
|
||||
| `DB_HOST` | | `localhost` | | Database host (a path starting with / is a Unix socket) |
|
||||
| `DB_PORT` | | `3306` | | Database port |
|
||||
| `DB_NAME` | `--dbname` `-d` | | | Database name |
|
||||
| `DB_USER` | | | `DB_USERNAME` | Database user |
|
||||
| `DB_PASSWORD` | | | `DB_PASSWORD_FILE` | Database password |
|
||||
| `DB_SSL_MODE` | | `disabled` | | TLS mode: disabled, preferred, required, verify_ca, verify_identity (1.x values 0 and 1 mean disabled and required) |
|
||||
| `DB_SSL_CA` | | `/etc/ssl/certs/ca-certificates.crt` | | CA certificate file, used with DB_SSL_MODE=verify_ca or verify_identity |
|
||||
| `DB_SSL_CERT` | | | | Client certificate file |
|
||||
| `DB_SSL_KEY` | | | | Client key file |
|
||||
| `BACKUP_ALL_DATABASES` | `--all-databases` `-a` | | | Back up every database, one file each |
|
||||
| `BACKUP_ALL_IN_ONE` | `--all-in-one` `-A` | | | Back up every database into a single file |
|
||||
| `BACKUP_EXCLUDE_DATABASES` | `--exclude-db` | | | Databases to skip with --all-databases, comma separated |
|
||||
| `TARGET_DB_URL` | | | `TARGET_DB_URL_FILE` | migrate: target connection URL |
|
||||
| `TARGET_DB_HOST` | | | | migrate: target host |
|
||||
| `TARGET_DB_PORT` | | `3306` | | migrate: target port |
|
||||
| `TARGET_DB_NAME` | | | | migrate: target database (default: the source name) |
|
||||
| `TARGET_DB_USER` | | | `TARGET_DB_USERNAME` | migrate: target user |
|
||||
| `TARGET_DB_PASSWORD` | | | `TARGET_DB_PASSWORD_FILE` | migrate: target password |
|
||||
| `TARGET_DB_SSL_MODE` | | `disabled` | | migrate: target TLS mode (same values as DB_SSL_MODE) |
|
||||
| `TARGET_DB_SSL_CA` | | `/etc/ssl/certs/ca-certificates.crt` | | migrate: target CA certificate file |
|
||||
| `MYSQL_CLIENT` | | `auto` | | Client tools: auto (mariadb-dump for MariaDB, Oracle mysqldump for MySQL), mysql or mariadb (auto, mysql, mariadb) |
|
||||
| `RESTORE_NO_DEFINER` | | `auto` | | Remove DEFINER clauses on restore (views, triggers, routines and events then belong to the restoring user); auto = when that user lacks SET_USER_ID/SUPER (auto, true, false) |
|
||||
|
||||
|
||||
## Available image registries
|
||||
|
||||
```
|
||||
docker pull jkaninda/mysql-bkup
|
||||
docker pull ghcr.io/jkaninda/mysql-bkup
|
||||
```
|
||||
|
||||
Documentation references Docker Hub, but all examples will work using ghcr.io just as well.
|
||||
Tags: `latest`, and immutable release tags (`v2.0.0`, `2.0.0`, `2.0`, `2`). Pin a release tag in production.
|
||||
|
||||
## Supported Engines
|
||||
|
||||
This image is developed and tested against the Docker CE engine and Kubernetes exclusively.
|
||||
While it may work against different implementations, there are no guarantees about support for non-Docker engines.
|
||||
|
||||
## References
|
||||
|
||||
We decided to publish this image as a simpler and more lightweight alternative because of the following requirements:
|
||||
|
||||
- The original image is based on `ubuntu` and requires additional tools, making it heavy.
|
||||
- This image is written in Go.
|
||||
- `arm64` and `arm/v7` architectures are supported.
|
||||
- Docker in Swarm mode is supported.
|
||||
- Kubernetes is supported.
|
||||
## PostgreSQL
|
||||
|
||||
Looking for PostgreSQL? See [pg-bkup](https://github.com/jkaninda/pg-bkup).
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License. See the LICENSE file for details.
|
||||
|
||||
## Authors
|
||||
|
||||
**Jonas Kaninda**
|
||||
- <https://github.com/jkaninda>
|
||||
|
||||
## Copyright
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
// Package cmd /
|
||||
/*****
|
||||
@author Jonas Kaninda
|
||||
@license MIT License <https://opensource.org/licenses/MIT>
|
||||
@Copyright © 2024 Jonas Kaninda
|
||||
**/
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/jkaninda/mysql-bkup/pkg"
|
||||
"github.com/jkaninda/mysql-bkup/utils"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var BackupCmd = &cobra.Command{
|
||||
Use: "backup ",
|
||||
Short: "Backup database operation",
|
||||
Example: utils.BackupExample,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if len(args) == 0 {
|
||||
pkg.StartBackup(cmd)
|
||||
} else {
|
||||
utils.Fatal("Error, no argument required")
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
//Backup
|
||||
BackupCmd.PersistentFlags().StringP("storage", "s", "local", "Storage. local or s3")
|
||||
BackupCmd.PersistentFlags().StringP("path", "P", "", "AWS S3 path without file name. eg: /custom_path or ssh remote path `/home/foo/backup`")
|
||||
BackupCmd.PersistentFlags().StringP("mode", "m", "default", "Execution mode. default or scheduled")
|
||||
BackupCmd.PersistentFlags().StringP("period", "", "0 1 * * *", "Schedule period time")
|
||||
BackupCmd.PersistentFlags().BoolP("prune", "", false, "Delete old backup, default disabled")
|
||||
BackupCmd.PersistentFlags().IntP("keep-last", "", 7, "Delete files created more than specified days ago, default 7 days")
|
||||
BackupCmd.PersistentFlags().BoolP("disable-compression", "", false, "Disable backup compression")
|
||||
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
// Package cmd /
|
||||
/*****
|
||||
@author Jonas Kaninda
|
||||
@license MIT License <https://opensource.org/licenses/MIT>
|
||||
@Copyright © 2024 Jonas Kaninda
|
||||
**/
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/jkaninda/mysql-bkup/pkg"
|
||||
"github.com/jkaninda/mysql-bkup/utils"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var MigrateCmd = &cobra.Command{
|
||||
Use: "migrate",
|
||||
Short: "Migrate database from a source database to a target database",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if len(args) == 0 {
|
||||
pkg.StartMigration(cmd)
|
||||
} else {
|
||||
utils.Fatal("Error, no argument required")
|
||||
|
||||
}
|
||||
|
||||
},
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/jkaninda/mysql-bkup/pkg"
|
||||
"github.com/jkaninda/mysql-bkup/utils"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var RestoreCmd = &cobra.Command{
|
||||
Use: "restore",
|
||||
Short: "Restore database operation",
|
||||
Example: utils.RestoreExample,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if len(args) == 0 {
|
||||
pkg.StartRestore(cmd)
|
||||
} else {
|
||||
utils.Fatal("Error, no argument required")
|
||||
|
||||
}
|
||||
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
//Restore
|
||||
RestoreCmd.PersistentFlags().StringP("file", "f", "", "File name of database")
|
||||
RestoreCmd.PersistentFlags().StringP("storage", "s", "local", "Storage. local or s3")
|
||||
RestoreCmd.PersistentFlags().StringP("path", "P", "", "AWS S3 path without file name. eg: /custom_path or ssh remote path `/home/foo/backup`")
|
||||
|
||||
}
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
// Package cmd /
|
||||
/*****
|
||||
@author Jonas Kaninda
|
||||
@license MIT License <https://opensource.org/licenses/MIT>
|
||||
@Copyright © 2024 Jonas Kaninda
|
||||
**/
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/jkaninda/mysql-bkup/utils"
|
||||
"github.com/spf13/cobra"
|
||||
"os"
|
||||
)
|
||||
|
||||
// rootCmd represents the base command when called without any subcommands
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "mysql-bkup [Command]",
|
||||
Short: "MySQL Backup tool, backup database to S3 or Object Storage",
|
||||
Long: `MySQL Database backup and restoration tool. Backup database to AWS S3 storage or any S3 Alternatives for Object Storage.`,
|
||||
Example: utils.MainExample,
|
||||
Version: appVersion,
|
||||
}
|
||||
var operation = ""
|
||||
|
||||
// Execute adds all child commands to the root command and sets flags appropriately.
|
||||
// This is called by main.main(). It only needs to happen once to the rootCmd.
|
||||
func Execute() {
|
||||
err := rootCmd.Execute()
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.PersistentFlags().StringP("dbname", "d", "", "Database name")
|
||||
rootCmd.AddCommand(VersionCmd)
|
||||
rootCmd.AddCommand(BackupCmd)
|
||||
rootCmd.AddCommand(RestoreCmd)
|
||||
rootCmd.AddCommand(MigrateCmd)
|
||||
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
// Package cmd /
|
||||
/*****
|
||||
@author Jonas Kaninda
|
||||
@license MIT License <https://opensource.org/licenses/MIT>
|
||||
@Copyright © 2024 Jonas Kaninda
|
||||
**/
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/spf13/cobra"
|
||||
"os"
|
||||
)
|
||||
|
||||
var appVersion = os.Getenv("VERSION")
|
||||
|
||||
var VersionCmd = &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Show version",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Version()
|
||||
},
|
||||
}
|
||||
|
||||
func Version() {
|
||||
fmt.Printf("Version: %s \n", appVersion)
|
||||
fmt.Println()
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
FROM golang:1.22.5 AS build
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the source code.
|
||||
COPY . .
|
||||
# Installs Go dependencies
|
||||
RUN go mod download
|
||||
|
||||
# Build
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/mysql-bkup
|
||||
|
||||
FROM ubuntu:24.04
|
||||
ENV DB_HOST="localhost"
|
||||
ENV DB_NAME=""
|
||||
ENV DB_USERNAME=""
|
||||
ENV DB_PASSWORD=""
|
||||
ENV DB_PORT=3306
|
||||
ENV STORAGE=local
|
||||
ENV AWS_S3_ENDPOINT=""
|
||||
ENV AWS_S3_BUCKET_NAME=""
|
||||
ENV AWS_ACCESS_KEY=""
|
||||
ENV AWS_SECRET_KEY=""
|
||||
ENV AWS_REGION="us-west-2"
|
||||
ENV AWS_S3_PATH=""
|
||||
ENV AWS_DISABLE_SSL="false"
|
||||
ENV GPG_PASSPHRASE=""
|
||||
ENV SSH_USER=""
|
||||
ENV SSH_REMOTE_PATH=""
|
||||
ENV SSH_PASSWORD=""
|
||||
ENV SSH_HOST_NAME=""
|
||||
ENV SSH_IDENTIFY_FILE=""
|
||||
ENV SSH_PORT="22"
|
||||
ENV TARGET_DB_HOST=""
|
||||
ENV TARGET_DB_PORT=3306
|
||||
ENV TARGET_DB_NAME="localhost"
|
||||
ENV TARGET_DB_USERNAME=""
|
||||
ENV TARGET_DB_PASSWORD=""
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ENV VERSION="v1.2.7"
|
||||
ENV BACKUP_CRON_EXPRESSION=""
|
||||
ENV TG_TOKEN=""
|
||||
ENV TG_CHAT_ID=""
|
||||
ARG WORKDIR="/config"
|
||||
ARG BACKUPDIR="/backup"
|
||||
ARG BACKUP_TMP_DIR="/tmp/backup"
|
||||
ARG BACKUP_CRON="/etc/cron.d/backup_cron"
|
||||
ARG BACKUP_CRON_SCRIPT="/usr/local/bin/backup_cron.sh"
|
||||
LABEL author="Jonas Kaninda"
|
||||
|
||||
RUN apt-get update -qq
|
||||
RUN apt install mysql-client supervisor cron gnupg -y
|
||||
|
||||
# Clear cache
|
||||
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN mkdir $WORKDIR
|
||||
RUN mkdir $BACKUPDIR
|
||||
RUN mkdir -p $BACKUP_TMP_DIR
|
||||
RUN chmod 777 $WORKDIR
|
||||
RUN chmod 777 $BACKUPDIR
|
||||
RUN chmod 777 $BACKUP_TMP_DIR
|
||||
RUN touch $BACKUP_CRON && \
|
||||
touch $BACKUP_CRON_SCRIPT && \
|
||||
chmod 777 $BACKUP_CRON && \
|
||||
chmod 777 $BACKUP_CRON_SCRIPT
|
||||
|
||||
COPY --from=build /app/mysql-bkup /usr/local/bin/mysql-bkup
|
||||
RUN chmod +x /usr/local/bin/mysql-bkup
|
||||
|
||||
RUN ln -s /usr/local/bin/mysql-bkup /usr/local/bin/bkup
|
||||
|
||||
ADD docker/supervisord.conf /etc/supervisor/supervisord.conf
|
||||
|
||||
# Create backup script and make it executable
|
||||
RUN echo '#!/bin/sh\n/usr/local/bin/mysql-bkup backup "$@"' > /usr/local/bin/backup && \
|
||||
chmod +x /usr/local/bin/backup
|
||||
# Create restore script and make it executable
|
||||
RUN echo '#!/bin/sh\n/usr/local/bin/mysql-bkup restore "$@"' > /usr/local/bin/restore && \
|
||||
chmod +x /usr/local/bin/restore
|
||||
# Create migrate script and make it executable
|
||||
RUN echo '#!/bin/sh\n/usr/local/bin/mysql-bkup migrate "$@"' > /usr/local/bin/migrate && \
|
||||
chmod +x /usr/local/bin/migrate
|
||||
|
||||
WORKDIR $WORKDIR
|
||||
ENTRYPOINT ["/usr/local/bin/mysql-bkup"]
|
||||
@@ -1,13 +0,0 @@
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
user=root
|
||||
logfile=/var/log/supervisor/supervisord.log
|
||||
pidfile=/var/run/supervisord.pid
|
||||
|
||||
[program:cron]
|
||||
command = /bin/bash -c "declare -p | grep -Ev '^declare -[[:alpha:]]*r' > /run/supervisord.env && /usr/sbin/cron -f -L 15"
|
||||
autostart=true
|
||||
autorestart=true
|
||||
user = root
|
||||
stderr_logfile=/var/log/cron.err.log
|
||||
stdout_logfile=/var/log/cron.out.log
|
||||
+1
-1
@@ -20,7 +20,7 @@ description: >- # this means to ignore newlines until "baseurl:"
|
||||
It supports local storage, AWS S3 or any S3 Alternatives for Object Storage, and SSH compatible storage.
|
||||
|
||||
baseurl: "" # the subpath of your site, e.g. /blog
|
||||
url: "jkaninda.github.io/mysql-bkup/" # the base hostname & protocol for your site, e.g. http://example.com
|
||||
url: "" # the base hostname & protocol for your site, e.g. http://example.com
|
||||
twitter_username: jonaskaninda
|
||||
github_username: jkaninda
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,72 @@
|
||||
---
|
||||
title: Azure Blob storage
|
||||
layout: default
|
||||
parent: How Tos
|
||||
nav_order: 5
|
||||
---
|
||||
|
||||
# Backup to Azure Blob Storage
|
||||
|
||||
To store your backups on Azure Blob Storage, you can configure the backup process to use the `--storage azure` option.
|
||||
|
||||
This section explains how to set up and configure Azure Blob-based backups.
|
||||
|
||||
---
|
||||
|
||||
## Configuration Steps
|
||||
|
||||
1. **Specify the Storage Type**
|
||||
Add the `--storage azure` flag to your backup command.
|
||||
|
||||
2. **Set the Blob Path**
|
||||
Optionally, specify a custom folder within your Azure Blob container where backups will be stored using the `--path` flag.
|
||||
Example: `--path my-custom-path`.
|
||||
|
||||
3. **Required Environment Variables**
|
||||
The following environment variables are mandatory for Azure Blob-based backups:
|
||||
|
||||
- `AZURE_STORAGE_CONTAINER_NAME`: The name of the Azure Blob container where backups will be stored.
|
||||
- `AZURE_STORAGE_ACCOUNT_NAME`: The name of your Azure Storage account.
|
||||
- `AZURE_STORAGE_ACCOUNT_KEY`: The access key for your Azure Storage account.
|
||||
|
||||
---
|
||||
|
||||
## Example Configuration
|
||||
|
||||
Below is an example `docker-compose.yml` configuration for backing up to Azure Blob Storage:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mysql-bkup:
|
||||
# In production, lock your image tag to a specific release version
|
||||
# instead of using `latest`. Check https://github.com/jkaninda/mysqlbkup/releases
|
||||
# for available releases.
|
||||
image: jkaninda/mysql-bkup
|
||||
container_name: mysql-bkup
|
||||
command: backup --storage azure -d database --path my-custom-path
|
||||
environment:
|
||||
- DB_PORT=3306
|
||||
- DB_HOST=mysql
|
||||
- DB_NAME=database
|
||||
- DB_USER=username
|
||||
- DB_PASSWORD=password
|
||||
## Azure Blob Configuration
|
||||
- AZURE_STORAGE_CONTAINER_NAME=backup-container
|
||||
- AZURE_STORAGE_ACCOUNT_NAME=account-name
|
||||
- AZURE_STORAGE_ACCOUNT_KEY=Ppby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==
|
||||
|
||||
# Ensure the mysql-bkup container is connected to the same network as your database
|
||||
networks:
|
||||
- web
|
||||
|
||||
networks:
|
||||
web:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Notes
|
||||
|
||||
- **Custom Path**: Use the `--path` flag to specify a folder within your Azure Blob container for organizing backups.
|
||||
- **Security**: Ensure your `AZURE_STORAGE_ACCOUNT_KEY` is kept secure and not exposed in public repositories.
|
||||
- **Compatibility**: This configuration works with Azure Blob Storage and other compatible storage solutions.
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
title: Backup all databases in the server
|
||||
layout: default
|
||||
parent: How Tos
|
||||
nav_order: 12
|
||||
---
|
||||
|
||||
# Backup All Databases
|
||||
|
||||
MySQL-Bkup supports backing up all databases on the server using the `--all-databases` (`-a`) flag. By default, this creates separate backup files for each database. If you prefer a single backup file, you can use the `--all-in-one` (`-A`) flag.
|
||||
|
||||
Backing up all databases is useful for creating a snapshot of the entire database server, whether for disaster recovery or migration purposes.
|
||||
## Backup Modes
|
||||
|
||||
### Separate Backup Files (Default)
|
||||
|
||||
Using --all-databases without --all-in-one creates individual backup files for each database.
|
||||
|
||||
- Creates separate backup files for each database.
|
||||
- Provides more flexibility in restoring individual databases or tables.
|
||||
- Can be more manageable in cases where different databases have different retention policies.
|
||||
- Might take slightly longer due to multiple file operations.
|
||||
- It is the default behavior when using the `--all-databases` flag.
|
||||
- It does not backup system databases (`information_schema`, `performance_schema`, `mysql`, `sys`, `innodb`,...).
|
||||
|
||||
**Command:**
|
||||
|
||||
```bash
|
||||
docker run --rm --network your_network_name \
|
||||
-v $PWD/backup:/backup/ \
|
||||
-e "DB_HOST=dbhost" \
|
||||
-e "DB_PORT=3306" \
|
||||
-e "DB_USER=username" \
|
||||
-e "DB_PASSWORD=password" \
|
||||
jkaninda/mysql-bkup backup --all-databases
|
||||
```
|
||||
### Single Backup File
|
||||
|
||||
Using --all-in-one (-A) creates a single backup file containing all databases.
|
||||
|
||||
- Creates a single backup file containing all databases.
|
||||
- Easier to manage if you need to restore everything at once.
|
||||
- Faster to back up and restore in bulk.
|
||||
- Can be problematic if you only need to restore a specific database or table.
|
||||
- It is recommended to use this option for disaster recovery purposes.
|
||||
- It backups system databases as well.
|
||||
|
||||
```bash
|
||||
docker run --rm --network your_network_name \
|
||||
-v $PWD/backup:/backup/ \
|
||||
-e "DB_HOST=dbhost" \
|
||||
-e "DB_PORT=3306" \
|
||||
-e "DB_USER=username" \
|
||||
-e "DB_PASSWORD=password" \
|
||||
jkaninda/mysql-bkup backup --all-in-one
|
||||
```
|
||||
|
||||
### When to Use Which?
|
||||
|
||||
- Use `--all-in-one` if you want a quick, simple backup for disaster recovery where you'll restore everything at once.
|
||||
- Use `--all-databases` if you need granularity in restoring specific databases or tables without affecting others.
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
title: Backup to FTP remote server
|
||||
layout: default
|
||||
parent: How Tos
|
||||
nav_order: 4
|
||||
---
|
||||
|
||||
# Backup to FTP Remote Server
|
||||
|
||||
To store your backups on an FTP remote server, you can configure the backup process to use the `--storage ftp` option.
|
||||
|
||||
This section explains how to set up and configure FTP-based backups.
|
||||
|
||||
---
|
||||
|
||||
## Configuration Steps
|
||||
|
||||
1. **Specify the Storage Type**
|
||||
Add the `--storage ftp` flag to your backup command.
|
||||
|
||||
2. **Set the Remote Path**
|
||||
Define the full remote path where backups will be stored using the `--path` flag or the `REMOTE_PATH` environment variable.
|
||||
Example: `--path /home/jkaninda/backups`.
|
||||
|
||||
3. **Required Environment Variables**
|
||||
The following environment variables are mandatory for FTP-based backups:
|
||||
|
||||
- `FTP_HOST`: The hostname or IP address of the FTP server.
|
||||
- `FTP_PORT`: The FTP port (default is `21`).
|
||||
- `FTP_USER`: The username for FTP authentication.
|
||||
- `FTP_PASSWORD`: The password for FTP authentication.
|
||||
- `REMOTE_PATH`: The directory on the FTP server where backups will be stored.
|
||||
|
||||
---
|
||||
|
||||
## Example Configuration
|
||||
|
||||
Below is an example `docker-compose.yml` configuration for backing up to an FTP remote server:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mysql-bkup:
|
||||
# In production, lock your image tag to a specific release version
|
||||
# instead of using `latest`. Check https://github.com/jkaninda/mysql-bkup/releases
|
||||
# for available releases.
|
||||
image: jkaninda/mysql-bkup
|
||||
container_name: mysql-bkup
|
||||
command: backup --storage ftp -d database
|
||||
environment:
|
||||
- DB_PORT=3306
|
||||
- DB_HOST=mysql
|
||||
- DB_NAME=database
|
||||
- DB_USER=username
|
||||
- DB_PASSWORD=password
|
||||
## FTP Configuration
|
||||
- FTP_HOST=hostname
|
||||
- FTP_PORT=21
|
||||
- FTP_USER=user
|
||||
- FTP_PASSWORD=password
|
||||
- REMOTE_PATH=/home/jkaninda/backups
|
||||
|
||||
# Ensure the mysql-bkup container is connected to the same network as your database
|
||||
networks:
|
||||
- web
|
||||
|
||||
networks:
|
||||
web:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Notes
|
||||
|
||||
- **Security**: FTP transmits data, including passwords, in plaintext. For better security, consider using SFTP (SSH File Transfer Protocol) or FTPS (FTP Secure) if supported by your server.
|
||||
- **Remote Path**: Ensure the `REMOTE_PATH` directory exists on the FTP server and is writable by the specified `FTP_USER`.
|
||||
+108
-115
@@ -4,130 +4,123 @@ layout: default
|
||||
parent: How Tos
|
||||
nav_order: 2
|
||||
---
|
||||
# Backup to AWS S3
|
||||
# Backup to AWS S3
|
||||
|
||||
{: .note }
|
||||
As described on local backup section, to change the storage of you backup and use S3 as storage. You need to add `--storage s3` (-s s3).
|
||||
You can also specify a specify folder where you want to save you data by adding `--path /my-custom-path` flag.
|
||||
To store your backups on AWS S3, you can configure the backup process to use the `--storage s3` option. This section explains how to set up and configure S3-based backups.
|
||||
|
||||
---
|
||||
|
||||
## Backup to S3
|
||||
## Configuration Steps
|
||||
|
||||
```yml
|
||||
services:
|
||||
mysql-bkup:
|
||||
# In production, it is advised to lock your image tag to a proper
|
||||
# release version instead of using `latest`.
|
||||
# Check https://github.com/jkaninda/mysql-bkup/releases
|
||||
# for a list of available releases.
|
||||
image: jkaninda/mysql-bkup
|
||||
container_name: mysql-bkup
|
||||
command: backup --storage s3 -d database --path /my-custom-path
|
||||
environment:
|
||||
- DB_PORT=3306
|
||||
- DB_HOST=mysql
|
||||
- DB_NAME=database
|
||||
- DB_USERNAME=username
|
||||
- DB_PASSWORD=password
|
||||
## AWS configurations
|
||||
- AWS_S3_ENDPOINT=https://s3.amazonaws.com
|
||||
- AWS_S3_BUCKET_NAME=backup
|
||||
- AWS_REGION="us-west-2"
|
||||
- AWS_ACCESS_KEY=xxxx
|
||||
- AWS_SECRET_KEY=xxxxx
|
||||
## In case you are using S3 alternative such as Minio and your Minio instance is not secured, you change it to true
|
||||
- AWS_DISABLE_SSL="false"
|
||||
|
||||
# mysql-bkup container must be connected to the same network with your database
|
||||
networks:
|
||||
- web
|
||||
networks:
|
||||
web:
|
||||
```
|
||||
1. **Specify the Storage Type**
|
||||
Add the `--storage s3` flag to your backup command.
|
||||
|
||||
### Recurring backups to S3
|
||||
2. **Set the S3 Path**
|
||||
Optionally, specify a custom folder within your S3 bucket where backups will be stored using the `--path` flag.
|
||||
Example: `--path /my-custom-path`.
|
||||
|
||||
As explained above, you need just to add AWS environment variables and specify the storage type `--storage s3`.
|
||||
In case you need to use recurring backups, you can use `--mode scheduled` and specify the periodical backup time by adding `--period "0 1 * * *"` flag as described below.
|
||||
3. **Required Environment Variables**
|
||||
The following environment variables are mandatory for S3-based backups:
|
||||
|
||||
```yml
|
||||
services:
|
||||
mysql-bkup:
|
||||
# In production, it is advised to lock your image tag to a proper
|
||||
# release version instead of using `latest`.
|
||||
# Check https://github.com/jkaninda/mysql-bkup/releases
|
||||
# for a list of available releases.
|
||||
image: jkaninda/mysql-bkup
|
||||
container_name: mysql-bkup
|
||||
command: backup --storage s3 -d my-database --mode scheduled --period "0 1 * * *"
|
||||
environment:
|
||||
- DB_PORT=3306
|
||||
- DB_HOST=mysql
|
||||
- DB_NAME=database
|
||||
- DB_USERNAME=username
|
||||
- DB_PASSWORD=password
|
||||
## AWS configurations
|
||||
- AWS_S3_ENDPOINT=https://s3.amazonaws.com
|
||||
- AWS_S3_BUCKET_NAME=backup
|
||||
- AWS_REGION="us-west-2"
|
||||
- AWS_ACCESS_KEY=xxxx
|
||||
- AWS_SECRET_KEY=xxxxx
|
||||
## In case you are using S3 alternative such as Minio and your Minio instance is not secured, you change it to true
|
||||
- AWS_DISABLE_SSL="false"
|
||||
# mysql-bkup container must be connected to the same network with your database
|
||||
networks:
|
||||
- web
|
||||
networks:
|
||||
web:
|
||||
```
|
||||
- `AWS_S3_ENDPOINT`: The S3 endpoint URL (e.g., `https://s3.amazonaws.com`).
|
||||
- `AWS_S3_BUCKET_NAME`: The name of the S3 bucket where backups will be stored.
|
||||
- `AWS_REGION`: The AWS region where the bucket is located (e.g., `us-west-2`).
|
||||
- `AWS_ACCESS_KEY`: Your AWS access key.
|
||||
- `AWS_SECRET_KEY`: Your AWS secret key.
|
||||
- `AWS_DISABLE_SSL`: Set to `"true"` if using an S3 alternative like Minio without SSL (default is `"false"`).
|
||||
- `AWS_FORCE_PATH_STYLE`: Set to `"true"` if using an S3 alternative like Minio (default is `"false"`).
|
||||
|
||||
## Deploy on Kubernetes
|
||||
---
|
||||
|
||||
For Kubernetes, you don't need to run it in scheduled mode. You can deploy it as CronJob.
|
||||
## Example Configuration
|
||||
|
||||
### Simple Kubernetes CronJob usage:
|
||||
Below is an example `docker-compose.yml` configuration for backing up to AWS S3:
|
||||
|
||||
```yaml
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: bkup-job
|
||||
spec:
|
||||
schedule: "0 1 * * *"
|
||||
jobTemplate:
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: mysql-bkup
|
||||
image: jkaninda/mysql-bkup
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- backup -s s3 --path /custom_path
|
||||
env:
|
||||
- name: DB_PORT
|
||||
value: "3306"
|
||||
- name: DB_HOST
|
||||
value: ""
|
||||
- name: DB_NAME
|
||||
value: ""
|
||||
- name: DB_USERNAME
|
||||
value: ""
|
||||
# Please use secret!
|
||||
- name: DB_PASSWORD
|
||||
value: ""
|
||||
- name: AWS_S3_ENDPOINT
|
||||
value: "https://s3.amazonaws.com"
|
||||
- name: AWS_S3_BUCKET_NAME
|
||||
value: "xxx"
|
||||
- name: AWS_REGION
|
||||
value: "us-west-2"
|
||||
- name: AWS_ACCESS_KEY
|
||||
value: "xxxx"
|
||||
- name: AWS_SECRET_KEY
|
||||
value: "xxxx"
|
||||
- name: AWS_DISABLE_SSL
|
||||
value: "false"
|
||||
restartPolicy: OnFailure
|
||||
```
|
||||
services:
|
||||
mysql-bkup:
|
||||
# In production, lock your image tag to a specific release version
|
||||
# instead of using `latest`. Check https://github.com/jkaninda/pg-bkup/releases
|
||||
# for available releases.
|
||||
image: jkaninda/mysql-bkup
|
||||
container_name: pg-bkup
|
||||
command: backup --storage s3 -d database --path /my-custom-path
|
||||
environment:
|
||||
- DB_PORT=5432
|
||||
- DB_HOST=postgres
|
||||
- DB_NAME=database
|
||||
- DB_USER=username
|
||||
- DB_PASSWORD=password
|
||||
## AWS Configuration
|
||||
- AWS_S3_ENDPOINT=https://s3.amazonaws.com
|
||||
- AWS_S3_BUCKET_NAME=backup
|
||||
- AWS_REGION=us-west-2
|
||||
- AWS_ACCESS_KEY=xxxx
|
||||
- AWS_SECRET_KEY=xxxxx
|
||||
## Optional: Disable SSL for S3 alternatives like Minio
|
||||
- AWS_DISABLE_SSL=false
|
||||
## Optional: Enable path-style access for S3 alternatives like Minio
|
||||
- AWS_FORCE_PATH_STYLE=false
|
||||
|
||||
# Ensure the mysql-bkup container is connected to the same network as your database
|
||||
networks:
|
||||
- web
|
||||
|
||||
networks:
|
||||
web:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Recurring Backups to S3
|
||||
|
||||
To schedule recurring backups to S3, use the `--cron-expression` flag or the `BACKUP_CRON_EXPRESSION` environment variable. This allows you to define a cron schedule for automated backups.
|
||||
|
||||
### Example: Recurring Backup Configuration
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mysql-bkup:
|
||||
# In production, lock your image tag to a specific release version
|
||||
# instead of using `latest`. Check https://github.com/jkaninda/mysql-bkup/releases
|
||||
# for available releases.
|
||||
image: jkaninda/mysql-bkup
|
||||
container_name: mysql-bkup
|
||||
command: backup --storage s3 -d database --cron-expression "0 1 * * *"
|
||||
environment:
|
||||
- DB_PORT=3306
|
||||
- DB_HOST=mysql
|
||||
- DB_NAME=database
|
||||
- DB_USER=username
|
||||
- DB_PASSWORD=password
|
||||
## AWS Configuration
|
||||
- AWS_S3_ENDPOINT=https://s3.amazonaws.com
|
||||
- AWS_S3_BUCKET_NAME=backup
|
||||
- AWS_REGION=us-west-2
|
||||
- AWS_ACCESS_KEY=xxxx
|
||||
- AWS_SECRET_KEY=xxxxx
|
||||
## Optional: Define a cron schedule for recurring backups
|
||||
#- BACKUP_CRON_EXPRESSION=0 1 * * *
|
||||
## Optional: Delete old backups after a specified number of days
|
||||
#- BACKUP_RETENTION_DAYS=7
|
||||
## Optional: Disable SSL for S3 alternatives like Minio
|
||||
- AWS_DISABLE_SSL=false
|
||||
## Optional: Enable path-style access for S3 alternatives like Minio
|
||||
- AWS_FORCE_PATH_STYLE=false
|
||||
|
||||
# Ensure the mysql-bkup container is connected to the same network as your database
|
||||
networks:
|
||||
- web
|
||||
|
||||
networks:
|
||||
web:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Notes
|
||||
|
||||
- **Cron Expression**: Use the `--cron-expression` flag or `BACKUP_CRON_EXPRESSION` environment variable to define the backup schedule. For example, `0 1 * * *` runs the backup daily at 1:00 AM.
|
||||
- **Backup Retention**: Optionally, use the `BACKUP_RETENTION_DAYS` environment variable to automatically delete backups older than a specified number of days.
|
||||
- **S3 Alternatives**: If using an S3 alternative like Minio, set `AWS_DISABLE_SSL=true` and `AWS_FORCE_PATH_STYLE=true` as needed. In the Compose list syntax, do not quote values: the quotes would become part of the value.
|
||||
|
||||
+114
-122
@@ -1,140 +1,132 @@
|
||||
---
|
||||
title: Backup to SSH
|
||||
title: Backup to SSH or SFTP
|
||||
layout: default
|
||||
parent: How Tos
|
||||
nav_order: 3
|
||||
---
|
||||
# Backup to SSH remote server
|
||||
# Backup to SFTP or SSH Remote Server
|
||||
|
||||
To store your backups on an `SFTP` or `SSH` remote server instead of the default storage, you can configure the backup process to use the `--storage ssh` or `--storage remote` option.
|
||||
This section explains how to set up and configure SSH-based backups.
|
||||
|
||||
As described for s3 backup section, to change the storage of your backup and use SSH Remote server as storage. You need to add `--storage ssh` or `--storage remote`.
|
||||
You need to add the full remote path by adding `--path /home/jkaninda/backups` flag or using `SSH_REMOTE_PATH` environment variable.
|
||||
---
|
||||
|
||||
{: .note }
|
||||
These environment variables are required for SSH backup `SSH_HOST_NAME`, `SSH_USER`, `SSH_REMOTE_PATH`, `SSH_IDENTIFY_FILE`, `SSH_PORT` or `SSH_PASSWORD` if you dont use a private key to access to your server.
|
||||
Accessing the remote server using password is not recommended, use private key instead.
|
||||
## Configuration Steps
|
||||
|
||||
```yml
|
||||
services:
|
||||
mysql-bkup:
|
||||
# In production, it is advised to lock your image tag to a proper
|
||||
# release version instead of using `latest`.
|
||||
# Check https://github.com/jkaninda/mysql-bkup/releases
|
||||
# for a list of available releases.
|
||||
image: jkaninda/mysql-bkup
|
||||
container_name: mysql-bkup
|
||||
command: backup --storage remote -d database
|
||||
volumes:
|
||||
- ./id_ed25519:/tmp/id_ed25519"
|
||||
environment:
|
||||
- DB_PORT=3306
|
||||
- DB_HOST=mysql
|
||||
#- DB_NAME=database
|
||||
- DB_USERNAME=username
|
||||
- DB_PASSWORD=password
|
||||
## SSH config
|
||||
- SSH_HOST_NAME="hostname"
|
||||
- SSH_PORT=22
|
||||
- SSH_USER=user
|
||||
- SSH_REMOTE_PATH=/home/jkaninda/backups
|
||||
- SSH_IDENTIFY_FILE=/tmp/id_ed25519
|
||||
## We advise you to use a private jey instead of password
|
||||
#- SSH_PASSWORD=password
|
||||
1. **Specify the Storage Type**
|
||||
Add the `--storage ssh` or `--storage remote` flag to your backup command.
|
||||
|
||||
# mysql-bkup container must be connected to the same network with your database
|
||||
networks:
|
||||
- web
|
||||
networks:
|
||||
web:
|
||||
```
|
||||
2. **Set the Remote Path**
|
||||
Define the full remote path where backups will be stored using the `--path` flag or the `REMOTE_PATH` environment variable.
|
||||
Example: `--path /home/jkaninda/backups`.
|
||||
|
||||
3. **Required Environment Variables**
|
||||
The following environment variables are mandatory for SSH-based backups:
|
||||
|
||||
### Recurring backups to SSH remote server
|
||||
- `SFTP_HOST`: The hostname or IP address of the remote server.
|
||||
- `SFTP_USER`: The username for SSH authentication.
|
||||
- `REMOTE_PATH`: The directory on the remote server where backups will be stored.
|
||||
- `SFTP_IDENTITY_FILE`: The path to the private key file for SSH authentication (`SFTP_IDENTITY_FILE` still works).
|
||||
- `SFTP_KNOWN_HOSTS`: A `known_hosts` file used to verify the server's host key. Host keys are verified by default since v2; `SFTP_INSECURE_IGNORE_HOST_KEY=true` turns the check off (not recommended).
|
||||
- `SFTP_PORT`: The SSH port (default is `22`).
|
||||
- `SFTP_PASSWORD`: (Optional) Use this only if you are not using a private key for authentication.
|
||||
|
||||
As explained above, you need just to add required environment variables and specify the storage type `--storage ssh`.
|
||||
You can use `--mode scheduled` and specify the periodical backup time by adding `--period "0 1 * * *"` flag as described below.
|
||||
{: .note }
|
||||
**Security Recommendation**: Using a private key (`SFTP_IDENTITY_FILE`) is strongly recommended over password-based authentication (`SFTP_PASSWORD`) for better security.
|
||||
|
||||
```yml
|
||||
services:
|
||||
mysql-bkup:
|
||||
# In production, it is advised to lock your image tag to a proper
|
||||
# release version instead of using `latest`.
|
||||
# Check https://github.com/jkaninda/mysql-bkup/releases
|
||||
# for a list of available releases.
|
||||
image: jkaninda/mysql-bkup
|
||||
container_name: mysql-bkup
|
||||
command: backup -d database --storage ssh --mode scheduled --period "0 1 * * *"
|
||||
volumes:
|
||||
- ./id_ed25519:/tmp/id_ed25519"
|
||||
environment:
|
||||
- DB_PORT=3306
|
||||
- DB_HOST=mysql
|
||||
- DB_NAME=database
|
||||
- DB_USERNAME=username
|
||||
- DB_PASSWORD=password
|
||||
## SSH config
|
||||
- SSH_HOST_NAME="hostname"
|
||||
- SSH_PORT=22
|
||||
- SSH_USER=user
|
||||
- SSH_REMOTE_PATH=/home/jkaninda/backups
|
||||
- SSH_IDENTIFY_FILE=/tmp/id_ed25519
|
||||
## We advise you to use a private jey instead of password
|
||||
#- SSH_PASSWORD=password
|
||||
# mysql-bkup container must be connected to the same network with your database
|
||||
networks:
|
||||
- web
|
||||
networks:
|
||||
web:
|
||||
```
|
||||
---
|
||||
|
||||
## Deploy on Kubernetes
|
||||
## Example Configuration
|
||||
|
||||
For Kubernetes, you don't need to run it in scheduled mode.
|
||||
You can deploy it as CronJob.
|
||||
|
||||
Simple Kubernetes CronJob usage:
|
||||
Below is an example `docker-compose.yml` configuration for backing up to an SSH remote server:
|
||||
|
||||
```yaml
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: bkup-job
|
||||
spec:
|
||||
schedule: "0 1 * * *"
|
||||
jobTemplate:
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: mysql-bkup
|
||||
image: jkaninda/mysql-bkup
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- backup -s ssh
|
||||
env:
|
||||
- name: DB_PORT
|
||||
value: "3306"
|
||||
- name: DB_HOST
|
||||
value: ""
|
||||
- name: DB_NAME
|
||||
value: ""
|
||||
- name: DB_USERNAME
|
||||
value: ""
|
||||
# Please use secret!
|
||||
- name: DB_PASSWORD
|
||||
value: ""
|
||||
- name: SSH_HOST_NAME
|
||||
value: ""
|
||||
- name: SSH_PORT
|
||||
value: "22"
|
||||
- name: SSH_USER
|
||||
value: "xxx"
|
||||
- name: SSH_REMOTE_PATH
|
||||
value: "/home/jkaninda/backups"
|
||||
- name: AWS_ACCESS_KEY
|
||||
value: "xxxx"
|
||||
- name: SSH_IDENTIFY_FILE
|
||||
value: "/tmp/id_ed25519"
|
||||
restartPolicy: Never
|
||||
```
|
||||
services:
|
||||
mysql-bkup:
|
||||
# In production, lock your image tag to a specific release version
|
||||
# instead of using `latest`. Check https://github.com/jkaninda/mysql-bkup/releases
|
||||
# for available releases.
|
||||
image: jkaninda/mysql-bkup
|
||||
container_name: mysql-bkup
|
||||
command: backup --storage remote -d database
|
||||
volumes:
|
||||
- ./id_ed25519:/tmp/id_ed25519
|
||||
environment:
|
||||
- DB_PORT=3306
|
||||
- DB_HOST=mysql
|
||||
- DB_NAME=database
|
||||
- DB_USER=username
|
||||
- DB_PASSWORD=password
|
||||
## SSH Configuration
|
||||
- SFTP_HOST=hostname
|
||||
- SFTP_PORT=22
|
||||
- SFTP_USER=user
|
||||
- REMOTE_PATH=/home/jkaninda/backups
|
||||
- SFTP_IDENTITY_FILE=/tmp/id_ed25519
|
||||
- SFTP_KNOWN_HOSTS=/config/known_hosts
|
||||
## Optional: Use password instead of private key (not recommended)
|
||||
#- SFTP_PASSWORD=password
|
||||
|
||||
# Ensure the mysql-bkup container is connected to the same network as your database
|
||||
networks:
|
||||
- web
|
||||
|
||||
networks:
|
||||
web:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Recurring Backups to SSH Remote Server
|
||||
|
||||
To schedule recurring backups, you can use the `--cron-expression` flag or the `BACKUP_CRON_EXPRESSION` environment variable.
|
||||
This allows you to define a cron schedule for automated backups.
|
||||
|
||||
### Example: Recurring Backup Configuration
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mysql-bkup:
|
||||
# In production, lock your image tag to a specific release version
|
||||
# instead of using `latest`. Check https://github.com/jkaninda/mysql-bkup/releases
|
||||
# for available releases.
|
||||
image: jkaninda/mysql-bkup
|
||||
container_name: mysql-bkup
|
||||
command: backup -d database --storage ssh --cron-expression "@daily"
|
||||
volumes:
|
||||
- ./id_ed25519:/tmp/id_ed25519
|
||||
environment:
|
||||
- DB_PORT=3306
|
||||
- DB_HOST=postgres
|
||||
- DB_NAME=database
|
||||
- DB_USER=username
|
||||
- DB_PASSWORD=password
|
||||
## SSH Configuration
|
||||
- SFTP_HOST=hostname
|
||||
- SFTP_PORT=22
|
||||
- SFTP_USER=user
|
||||
- REMOTE_PATH=/home/jkaninda/backups
|
||||
- SFTP_IDENTITY_FILE=/tmp/id_ed25519
|
||||
- SFTP_KNOWN_HOSTS=/config/known_hosts
|
||||
## Optional: Delete old backups after a specified number of days
|
||||
#- BACKUP_RETENTION_DAYS=7
|
||||
## Optional: Use password instead of private key (not recommended)
|
||||
#- SFTP_PASSWORD=password
|
||||
|
||||
# Ensure the mysql-bkup container is connected to the same network as your database
|
||||
networks:
|
||||
- web
|
||||
|
||||
networks:
|
||||
web:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Notes
|
||||
|
||||
- **Cron Expression**: Use the `--cron-expression` flag or `BACKUP_CRON_EXPRESSION` environment variable to define the backup schedule. For example, `0 1 * * *` runs the backup daily at 1:00 AM.
|
||||
- **Backup Retention**: Optionally, use the `BACKUP_RETENTION_DAYS` environment variable to automatically delete backups older than a specified number of days.
|
||||
- **Security**: Always prefer private key authentication (`SFTP_IDENTITY_FILE`) over password-based authentication (`SFTP_PASSWORD`) for enhanced security.
|
||||
|
||||
---
|
||||
+65
-31
@@ -5,26 +5,35 @@ parent: How Tos
|
||||
nav_order: 1
|
||||
---
|
||||
|
||||
# Backup database
|
||||
# Backup Database
|
||||
|
||||
To backup the database, you need to add `backup` command.
|
||||
To back up your database, use the `backup` command.
|
||||
|
||||
This section explains how to configure and run backups, including recurring backups, using Docker or Kubernetes.
|
||||
|
||||
---
|
||||
|
||||
## Default Configuration
|
||||
|
||||
- **Storage**: By default, backups are stored locally in the `/backup` directory.
|
||||
- **Compression**: Backups are compressed using `gzip` by default. Use the `--disable-compression` flag to disable compression.
|
||||
- **Security**: It is recommended to create a dedicated user with read-only access for backup tasks.
|
||||
|
||||
{: .note }
|
||||
The default storage is local storage mounted to __/backup__. The backup is compressed by default using gzip. The flag __`disable-compression`__ can be used when you need to disable backup compression.
|
||||
The backup process supports recurring backups on Docker or Docker Swarm. On Kubernetes, it can be deployed as a CronJob.
|
||||
|
||||
{: .warning }
|
||||
Creating a user for backup tasks who has read-only access is recommended!
|
||||
---
|
||||
|
||||
The backup process can be run in scheduled mode for the recurring backups.
|
||||
It handles __recurring__ backups of mysql database on Docker and can be deployed as __CronJob on Kubernetes__ using local, AWS S3 or SSH compatible storage.
|
||||
## Example: Basic Backup Configuration
|
||||
|
||||
```yml
|
||||
Below is an example `docker-compose.yml` configuration for backing up a database:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mysql-bkup:
|
||||
# In production, it is advised to lock your image tag to a proper
|
||||
# release version instead of using `latest`.
|
||||
# Check https://github.com/jkaninda/mysql-bkup/releases
|
||||
# for a list of available releases.
|
||||
# In production, lock your image tag to a specific release version
|
||||
# instead of using `latest`. Check https://github.com/jkaninda/mysql-bkup/releases
|
||||
# for available releases.
|
||||
image: jkaninda/mysql-bkup
|
||||
container_name: mysql-bkup
|
||||
command: backup -d database
|
||||
@@ -34,50 +43,75 @@ services:
|
||||
- DB_PORT=3306
|
||||
- DB_HOST=mysql
|
||||
- DB_NAME=database
|
||||
- DB_USERNAME=username
|
||||
- DB_USER=username
|
||||
- DB_PASSWORD=password
|
||||
# mysql-bkup container must be connected to the same network with your database
|
||||
|
||||
# Ensure the mysql-bkup container is connected to the same network as your database
|
||||
networks:
|
||||
- web
|
||||
|
||||
networks:
|
||||
web:
|
||||
```
|
||||
|
||||
### Backup using Docker CLI
|
||||
---
|
||||
|
||||
```shell
|
||||
docker run --rm --network your_network_name \
|
||||
-v $PWD/backup:/backup/ \
|
||||
-e "DB_HOST=dbhost" \
|
||||
-e "DB_USERNAME=username" \
|
||||
-e "DB_PASSWORD=password" \
|
||||
jkaninda/mysql-bkup backup -d database_name
|
||||
## Backup Using Docker CLI
|
||||
|
||||
You can also run backups directly using the Docker CLI:
|
||||
|
||||
```bash
|
||||
docker run --rm --network your_network_name \
|
||||
-v $PWD/backup:/backup/ \
|
||||
-e "DB_HOST=dbhost" \
|
||||
-e "DB_USER=username" \
|
||||
-e "DB_PASSWORD=password" \
|
||||
jkaninda/pg-bkup backup -d database_name
|
||||
```
|
||||
|
||||
In case you need to use recurring backups, you can use `--mode scheduled` and specify the periodical backup time by adding `--period "0 1 * * *"` flag as described below.
|
||||
---
|
||||
|
||||
```yml
|
||||
## Recurring Backups
|
||||
|
||||
To schedule recurring backups, use the `--cron-expression (-e)` flag or the `BACKUP_CRON_EXPRESSION` environment variable. This allows you to define a cron schedule for automated backups.
|
||||
|
||||
### Example: Recurring Backup Configuration
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mysql-bkup:
|
||||
# In production, it is advised to lock your image tag to a proper
|
||||
# release version instead of using `latest`.
|
||||
# Check https://github.com/jkaninda/mysql-bkup/releases
|
||||
# for a list of available releases.
|
||||
# In production, lock your image tag to a specific release version
|
||||
# instead of using `latest`. Check https://github.com/jkaninda/mysql-bkup/releases
|
||||
# for available releases.
|
||||
image: jkaninda/mysql-bkup
|
||||
container_name: mysql-bkup
|
||||
command: backup -d database --mode scheduled --period "0 1 * * *"
|
||||
command: backup -d database --cron-expression @midnight
|
||||
volumes:
|
||||
- ./backup:/backup
|
||||
environment:
|
||||
- DB_PORT=3306
|
||||
- DB_HOST=mysql
|
||||
- DB_NAME=database
|
||||
- DB_USERNAME=username
|
||||
- DB_USER=username
|
||||
- DB_PASSWORD=password
|
||||
# mysql-bkup container must be connected to the same network with your database
|
||||
## Optional: Define a cron schedule for recurring backups
|
||||
- BACKUP_CRON_EXPRESSION=@midnight
|
||||
## Optional: Delete old backups after a specified number of days
|
||||
#- BACKUP_RETENTION_DAYS=7
|
||||
|
||||
# Ensure the mysql-bkup container is connected to the same network as your database
|
||||
networks:
|
||||
- web
|
||||
|
||||
networks:
|
||||
web:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Notes
|
||||
|
||||
- **Cron Expression**: Use the `--cron-expression (-e)` flag or `BACKUP_CRON_EXPRESSION` environment variable to define the backup schedule. For example:
|
||||
- `@midnight`: Runs the backup daily at midnight.
|
||||
- `0 1 * * *`: Runs the backup daily at 1:00 AM.
|
||||
- **Backup Retention**: Optionally, use the `BACKUP_RETENTION_DAYS` environment variable to automatically delete backups older than a specified number of days.
|
||||
@@ -2,15 +2,20 @@
|
||||
title: Deploy on Kubernetes
|
||||
layout: default
|
||||
parent: How Tos
|
||||
nav_order: 8
|
||||
nav_order: 9
|
||||
---
|
||||
|
||||
## Deploy on Kubernetes
|
||||
# Deploy on Kubernetes
|
||||
|
||||
To deploy MySQL Backup on Kubernetes, you can use Job to backup or Restore your database.
|
||||
For recurring backup you can use CronJob, you don't need to run it in scheduled mode. as described bellow.
|
||||
To deploy MySQL Backup on Kubernetes, you can use a `Job` for one-time backups or restores, and a `CronJob` for recurring backups.
|
||||
|
||||
## Backup to S3 storage
|
||||
Below are examples for different use cases.
|
||||
|
||||
---
|
||||
|
||||
## Backup Job to S3 Storage
|
||||
|
||||
This example demonstrates how to configure a Kubernetes `Job` to back up a MySQL database to an S3-compatible storage.
|
||||
|
||||
```yaml
|
||||
apiVersion: batch/v1
|
||||
@@ -21,48 +26,53 @@ spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: mysql-bkup
|
||||
# In production, it is advised to lock your image tag to a proper
|
||||
# release version instead of using `latest`.
|
||||
# Check https://github.com/jkaninda/mysql-bkup/releases
|
||||
# for a list of available releases.
|
||||
image: jkaninda/mysql-bkup
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- backup --storage s3
|
||||
resources:
|
||||
limits:
|
||||
memory: "128Mi"
|
||||
cpu: "500m"
|
||||
env:
|
||||
- name: DB_PORT
|
||||
value: "3306"
|
||||
- name: DB_HOST
|
||||
value: ""
|
||||
- name: DB_NAME
|
||||
value: "dbname"
|
||||
- name: DB_USERNAME
|
||||
value: "username"
|
||||
# Please use secret!
|
||||
- name: DB_PASSWORD
|
||||
value: ""
|
||||
- name: AWS_S3_ENDPOINT
|
||||
value: "https://s3.amazonaws.com"
|
||||
- name: AWS_S3_BUCKET_NAME
|
||||
value: "xxx"
|
||||
- name: AWS_REGION
|
||||
value: "us-west-2"
|
||||
- name: AWS_ACCESS_KEY
|
||||
value: "xxxx"
|
||||
- name: AWS_SECRET_KEY
|
||||
value: "xxxx"
|
||||
- name: AWS_DISABLE_SSL
|
||||
value: "false"
|
||||
- name: mysql-bkup
|
||||
# In production, lock your image tag to a specific release version
|
||||
# instead of using `latest`. Check https://github.com/jkaninda/mysql-bkup/releases
|
||||
# for available releases.
|
||||
image: jkaninda/mysql-bkup
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- backup --storage s3
|
||||
resources:
|
||||
limits:
|
||||
memory: "128Mi"
|
||||
cpu: "500m"
|
||||
env:
|
||||
- name: DB_PORT
|
||||
value: "3306"
|
||||
- name: DB_HOST
|
||||
value: ""
|
||||
- name: DB_NAME
|
||||
value: ""
|
||||
- name: DB_USER
|
||||
value: ""
|
||||
# Use Kubernetes Secrets for sensitive data like passwords
|
||||
- name: DB_PASSWORD
|
||||
value: ""
|
||||
- name: AWS_S3_ENDPOINT
|
||||
value: "https://s3.amazonaws.com"
|
||||
- name: AWS_S3_BUCKET_NAME
|
||||
value: "xxx"
|
||||
- name: AWS_REGION
|
||||
value: "us-west-2"
|
||||
- name: AWS_ACCESS_KEY
|
||||
value: "xxxx"
|
||||
- name: AWS_SECRET_KEY
|
||||
value: "xxxx"
|
||||
- name: AWS_DISABLE_SSL
|
||||
value: "false"
|
||||
- name: AWS_FORCE_PATH_STYLE
|
||||
value: "false"
|
||||
restartPolicy: Never
|
||||
```
|
||||
|
||||
## Backup Job to SSH remote server
|
||||
---
|
||||
|
||||
## Backup Job to SSH Remote Server
|
||||
|
||||
This example demonstrates how to configure a Kubernetes `Job` to back up a MySQL database to an SSH remote server.
|
||||
|
||||
```yaml
|
||||
apiVersion: batch/v1
|
||||
@@ -75,19 +85,14 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: mysql-bkup
|
||||
# In production, it is advised to lock your image tag to a proper
|
||||
# release version instead of using `latest`.
|
||||
# Check https://github.com/jkaninda/mysql-bkup/releases
|
||||
# for a list of available releases.
|
||||
# In production, lock your image tag to a specific release version
|
||||
# instead of using `latest`. Check https://github.com/jkaninda/mysql-bkup/releases
|
||||
# for available releases.
|
||||
image: jkaninda/mysql-bkup
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- bkup
|
||||
- backup
|
||||
- --storage
|
||||
- ssh
|
||||
- --disable-compression
|
||||
- backup --storage ssh --disable-compression
|
||||
resources:
|
||||
limits:
|
||||
memory: "128Mi"
|
||||
@@ -99,29 +104,33 @@ spec:
|
||||
value: ""
|
||||
- name: DB_NAME
|
||||
value: "dbname"
|
||||
- name: DB_USERNAME
|
||||
value: "username"
|
||||
# Please use secret!
|
||||
- name: DB_USER
|
||||
value: "postgres"
|
||||
# Use Kubernetes Secrets for sensitive data like passwords
|
||||
- name: DB_PASSWORD
|
||||
value: ""
|
||||
- name: SSH_HOST_NAME
|
||||
- name: SFTP_HOST
|
||||
value: "xxx"
|
||||
- name: SSH_PORT
|
||||
- name: SFTP_PORT
|
||||
value: "22"
|
||||
- name: SSH_USER
|
||||
- name: SFTP_USER
|
||||
value: "xxx"
|
||||
- name: SSH_PASSWORD
|
||||
- name: SFTP_PASSWORD
|
||||
value: "xxxx"
|
||||
- name: SSH_REMOTE_PATH
|
||||
- name: REMOTE_PATH
|
||||
value: "/home/toto/backup"
|
||||
# Optional, required if you want to encrypt your backup
|
||||
# Optional: Required if you want to encrypt your backup
|
||||
- name: GPG_PASSPHRASE
|
||||
value: "xxxx"
|
||||
restartPolicy: Never
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Restore Job
|
||||
|
||||
This example demonstrates how to configure a Kubernetes `Job` to restore a MySQL database from a backup stored on an SSH remote server.
|
||||
|
||||
```yaml
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
@@ -133,52 +142,51 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: mysql-bkup
|
||||
# In production, it is advised to lock your image tag to a proper
|
||||
# release version instead of using `latest`.
|
||||
# Check https://github.com/jkaninda/mysql-bkup/releases
|
||||
# for a list of available releases.
|
||||
# In production, lock your image tag to a specific release version
|
||||
# instead of using `latest`. Check https://github.com/jkaninda/mysql-bkup/releases
|
||||
# for available releases.
|
||||
image: jkaninda/mysql-bkup
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- bkup
|
||||
- restore
|
||||
- --storage
|
||||
- ssh
|
||||
- --file store_20231219_022941.sql.gz
|
||||
- restore --storage ssh --file store_20231219_022941.sql.gz
|
||||
resources:
|
||||
limits:
|
||||
memory: "128Mi"
|
||||
cpu: "500m"
|
||||
env:
|
||||
- name: DB_PORT
|
||||
value: "3306"
|
||||
- name: DB_HOST
|
||||
value: ""
|
||||
- name: DB_NAME
|
||||
value: "dbname"
|
||||
- name: DB_USERNAME
|
||||
value: "username"
|
||||
# Please use secret!
|
||||
- name: DB_PASSWORD
|
||||
value: ""
|
||||
- name: SSH_HOST_NAME
|
||||
value: "xxx"
|
||||
- name: SSH_PORT
|
||||
value: "22"
|
||||
- name: SSH_USER
|
||||
value: "xxx"
|
||||
- name: SSH_PASSWORD
|
||||
value: "xxxx"
|
||||
- name: SSH_REMOTE_PATH
|
||||
value: "/home/xxxx/backup"
|
||||
# Optional, required if your backup was encrypted
|
||||
#- name: GPG_PASSPHRASE
|
||||
# value: "xxxx"
|
||||
- name: DB_PORT
|
||||
value: "3306"
|
||||
- name: DB_HOST
|
||||
value: ""
|
||||
- name: DB_NAME
|
||||
value: "dbname"
|
||||
- name: DB_USER
|
||||
value: "postgres"
|
||||
# Use Kubernetes Secrets for sensitive data like passwords
|
||||
- name: DB_PASSWORD
|
||||
value: ""
|
||||
- name: SFTP_HOST
|
||||
value: "xxx"
|
||||
- name: SFTP_PORT
|
||||
value: "22"
|
||||
- name: SFTP_USER
|
||||
value: "xxx"
|
||||
- name: SFTP_PASSWORD
|
||||
value: "xxxx"
|
||||
- name: REMOTE_PATH
|
||||
value: "/home/toto/backup"
|
||||
# Optional: Required if your backup was encrypted
|
||||
#- name: GPG_PASSPHRASE
|
||||
# value: "xxxx"
|
||||
restartPolicy: Never
|
||||
```
|
||||
|
||||
## Recurring backup
|
||||
---
|
||||
|
||||
## Recurring Backup with CronJob
|
||||
|
||||
This example demonstrates how to configure a Kubernetes `CronJob` for recurring backups to an SSH remote server.
|
||||
|
||||
```yaml
|
||||
apiVersion: batch/v1
|
||||
@@ -193,52 +201,51 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: mysql-bkup
|
||||
# In production, lock your image tag to a specific release version
|
||||
# instead of using `latest`. Check https://github.com/jkaninda/mysql-bkup/releases
|
||||
# for available releases.
|
||||
image: jkaninda/mysql-bkup
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- bkup
|
||||
- backup
|
||||
- --storage
|
||||
- ssh
|
||||
- --disable-compression
|
||||
- backup --storage ssh --disable-compression
|
||||
resources:
|
||||
limits:
|
||||
memory: "128Mi"
|
||||
cpu: "500m"
|
||||
env:
|
||||
- name: DB_PORT
|
||||
value: "3306"
|
||||
- name: DB_HOST
|
||||
value: ""
|
||||
- name: DB_NAME
|
||||
value: "username"
|
||||
- name: DB_USERNAME
|
||||
value: "username"
|
||||
# Please use secret!
|
||||
- name: DB_PASSWORD
|
||||
value: ""
|
||||
- name: SSH_HOST_NAME
|
||||
value: "xxx"
|
||||
- name: SSH_PORT
|
||||
value: "xxx"
|
||||
- name: SSH_USER
|
||||
value: "jkaninda"
|
||||
- name: SSH_REMOTE_PATH
|
||||
value: "/home/jkaninda/backup"
|
||||
- name: SSH_PASSWORD
|
||||
value: "password"
|
||||
# Optional, required if you want to encrypt your backup
|
||||
#- name: GPG_PASSPHRASE
|
||||
# value: "xxx"
|
||||
- name: DB_PORT
|
||||
value: "3306"
|
||||
- name: DB_HOST
|
||||
value: ""
|
||||
- name: DB_NAME
|
||||
value: "test"
|
||||
- name: DB_USER
|
||||
value: "postgres"
|
||||
# Use Kubernetes Secrets for sensitive data like passwords
|
||||
- name: DB_PASSWORD
|
||||
value: ""
|
||||
- name: SFTP_HOST
|
||||
value: "192.168.1.16"
|
||||
- name: SFTP_PORT
|
||||
value: "2222"
|
||||
- name: SFTP_USER
|
||||
value: "jkaninda"
|
||||
- name: REMOTE_PATH
|
||||
value: "/config/backup"
|
||||
- name: SFTP_PASSWORD
|
||||
value: "password"
|
||||
# Optional: Required if you want to encrypt your backup
|
||||
#- name: GPG_PASSPHRASE
|
||||
# value: "xxx"
|
||||
restartPolicy: Never
|
||||
```
|
||||
|
||||
## Kubernetes Rootless
|
||||
---
|
||||
|
||||
This image also supports Kubernetes security context, you can run it in Rootless environment.
|
||||
It has been tested on Openshift, it works well.
|
||||
Deployment on OpenShift is supported, you need to remove `securityContext` section on your yaml file.
|
||||
## Kubernetes Rootless Deployment
|
||||
|
||||
This example demonstrates how to run the backup container in a rootless environment, suitable for platforms like OpenShift.
|
||||
|
||||
```yaml
|
||||
apiVersion: batch/v1
|
||||
@@ -256,48 +263,107 @@ spec:
|
||||
runAsGroup: 3000
|
||||
fsGroup: 2000
|
||||
containers:
|
||||
# In production, it is advised to lock your image tag to a proper
|
||||
# release version instead of using `latest`.
|
||||
# Check https://github.com/jkaninda/mysql-bkup/releases
|
||||
# for a list of available releases.
|
||||
- name: mysql-bkup
|
||||
image: jkaninda/mysql-bkup
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- bkup
|
||||
- backup
|
||||
- --storage
|
||||
- ssh
|
||||
- --disable-compression
|
||||
resources:
|
||||
limits:
|
||||
memory: "128Mi"
|
||||
cpu: "500m"
|
||||
env:
|
||||
- name: DB_PORT
|
||||
value: "3306"
|
||||
- name: DB_HOST
|
||||
value: ""
|
||||
- name: DB_NAME
|
||||
value: "xxx"
|
||||
- name: DB_USERNAME
|
||||
value: "xxx"
|
||||
# Please use secret!
|
||||
- name: DB_PASSWORD
|
||||
value: ""
|
||||
- name: SSH_HOST_NAME
|
||||
value: "xxx"
|
||||
- name: SSH_PORT
|
||||
value: "22"
|
||||
- name: SSH_USER
|
||||
value: "jkaninda"
|
||||
- name: SSH_REMOTE_PATH
|
||||
value: "/home/jkaninda/backup"
|
||||
- name: SSH_PASSWORD
|
||||
value: "password"
|
||||
# Optional, required if you want to encrypt your backup
|
||||
- name: mysql-bkup
|
||||
# In production, lock your image tag to a specific release version
|
||||
# instead of using `latest`. Check https://github.com/jkaninda/mysql-bkup/releases
|
||||
# for available releases.
|
||||
image: jkaninda/mysql-bkup
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- backup --storage ssh --disable-compression
|
||||
resources:
|
||||
limits:
|
||||
memory: "128Mi"
|
||||
cpu: "500m"
|
||||
env:
|
||||
- name: DB_PORT
|
||||
value: "3306"
|
||||
- name: DB_HOST
|
||||
value: ""
|
||||
- name: DB_NAME
|
||||
value: "test"
|
||||
- name: DB_USER
|
||||
value: "postgres"
|
||||
# Use Kubernetes Secrets for sensitive data like passwords
|
||||
- name: DB_PASSWORD
|
||||
value: ""
|
||||
- name: SFTP_HOST
|
||||
value: "192.168.1.16"
|
||||
- name: SFTP_PORT
|
||||
value: "2222"
|
||||
- name: SFTP_USER
|
||||
value: "jkaninda"
|
||||
- name: REMOTE_PATH
|
||||
value: "/config/backup"
|
||||
- name: SFTP_PASSWORD
|
||||
value: "password"
|
||||
# Optional: Required if you want to encrypt your backup
|
||||
#- name: GPG_PASSPHRASE
|
||||
# value: "xxx"
|
||||
restartPolicy: OnFailure
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migrate Database
|
||||
|
||||
This example demonstrates how to configure a Kubernetes `Job` to migrate a MySQL database from one server to another.
|
||||
|
||||
```yaml
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: migrate-db
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 100
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: mysql-bkup
|
||||
# In production, lock your image tag to a specific release version
|
||||
# instead of using `latest`. Check https://github.com/jkaninda/mysql-bkup/releases
|
||||
# for available releases.
|
||||
image: jkaninda/mysql-bkup
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- migrate
|
||||
resources:
|
||||
limits:
|
||||
memory: "128Mi"
|
||||
cpu: "500m"
|
||||
env:
|
||||
## Source Database
|
||||
- name: DB_HOST
|
||||
value: "postgres"
|
||||
- name: DB_PORT
|
||||
value: "3306"
|
||||
- name: DB_NAME
|
||||
value: "dbname"
|
||||
- name: DB_USER
|
||||
value: "username"
|
||||
- name: DB_PASSWORD
|
||||
value: "password"
|
||||
## Target Database
|
||||
- name: TARGET_DB_HOST
|
||||
value: "target-postgres"
|
||||
- name: TARGET_DB_PORT
|
||||
value: "3306"
|
||||
- name: TARGET_DB_NAME
|
||||
value: "dbname"
|
||||
- name: TARGET_DB_USER
|
||||
value: "username"
|
||||
- name: TARGET_DB_PASSWORD
|
||||
value: "password"
|
||||
restartPolicy: Never
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Notes
|
||||
|
||||
- **Security**: Always use Kubernetes Secrets for sensitive data like passwords and access keys.
|
||||
- **Resource Limits**: Adjust resource limits (`memory` and `cpu`) based on your workload requirements.
|
||||
- **Cron Schedule**: Use standard cron expressions for scheduling recurring backups.
|
||||
- **Rootless Deployment**: The image supports running in rootless environments, making it suitable for platforms like OpenShift.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: Update deprecated configurations
|
||||
layout: default
|
||||
parent: How Tos
|
||||
nav_order: 11
|
||||
---
|
||||
+105
-20
@@ -2,34 +2,37 @@
|
||||
title: Encrypt backups using GPG
|
||||
layout: default
|
||||
parent: How Tos
|
||||
nav_order: 7
|
||||
nav_order: 8
|
||||
---
|
||||
# Encrypt backup
|
||||
# Encrypt Backup
|
||||
|
||||
The image supports encrypting backups using GPG out of the box. In case a `GPG_PASSPHRASE` environment variable is set, the backup archive will be encrypted using the given key and saved as a sql.gpg file instead or sql.gz.gpg.
|
||||
The image supports encrypting backups using one of two methods: **GPG with a passphrase** or **GPG with a public key**. When a `GPG_PASSPHRASE` or `GPG_PUBLIC_KEY` environment variable is set, the backup archive will be encrypted and saved as a `.sql.gpg` or `.sql.gz.gpg` file.
|
||||
|
||||
{: .warning }
|
||||
To restore an encrypted backup, you need to provide the same GPG passphrase used during backup process.
|
||||
To restore an encrypted backup, you must provide the same GPG passphrase or private key used during the backup process.
|
||||
|
||||
To decrypt manually, you need to install `gnupg`
|
||||
---
|
||||
|
||||
### Decrypt backup
|
||||
## Key Features
|
||||
|
||||
```shell
|
||||
gpg --batch --passphrase "my-passphrase" \
|
||||
--output database_20240730_044201.sql.gz \
|
||||
--decrypt database_20240730_044201.sql.gz.gpg
|
||||
```
|
||||
- **Cipher Algorithm**: `aes256`
|
||||
- **Automatic Restoration**: Backups encrypted with a GPG passphrase can be restored automatically without manual decryption.
|
||||
- **Manual Decryption**: Backups encrypted with a GPG public key require manual decryption before restoration.
|
||||
|
||||
### Backup
|
||||
---
|
||||
|
||||
```yml
|
||||
## Using GPG Passphrase
|
||||
|
||||
To encrypt backups using a GPG passphrase, set the `GPG_PASSPHRASE` environment variable. The backup will be encrypted and can be restored automatically.
|
||||
|
||||
### Example Configuration
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mysql-bkup:
|
||||
# In production, it is advised to lock your image tag to a proper
|
||||
# release version instead of using `latest`.
|
||||
# Check https://github.com/jkaninda/mysql-bkup/releases
|
||||
# for a list of available releases.
|
||||
# In production, lock your image tag to a specific release version
|
||||
# instead of using `latest`. Check https://github.com/jkaninda/mysql-bkup/releases
|
||||
# for available releases.
|
||||
image: jkaninda/mysql-bkup
|
||||
container_name: mysql-bkup
|
||||
command: backup -d database
|
||||
@@ -39,13 +42,95 @@ services:
|
||||
- DB_PORT=3306
|
||||
- DB_HOST=mysql
|
||||
- DB_NAME=database
|
||||
- DB_USERNAME=username
|
||||
- DB_USER=username
|
||||
- DB_PASSWORD=password
|
||||
## Required to encrypt backup
|
||||
- GPG_PASSPHRASE=my-secure-passphrase
|
||||
# mysql-bkup container must be connected to the same network with your database
|
||||
# Ensure the pg-bkup container is connected to the same network as your database
|
||||
networks:
|
||||
- web
|
||||
|
||||
networks:
|
||||
web:
|
||||
```
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Using GPG Public Key
|
||||
|
||||
To encrypt backups using a GPG public key, set the `GPG_PUBLIC_KEY` environment variable to the path of your public key file. To restore such backups, set `GPG_PRIVATE_KEY` (a file path or the inline armored key; `/config/gnupg/private_key.asc` is picked up automatically) and, if the key is protected, `GPG_PRIVATE_KEY_PASSPHRASE` (defaults to `GPG_PASSPHRASE`).
|
||||
|
||||
### Example Configuration
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mysql-bkup:
|
||||
# In production, lock your image tag to a specific release version
|
||||
# instead of using `latest`. Check https://github.com/jkaninda/mysql-bkup/releases
|
||||
# for available releases.
|
||||
image: jkaninda/mysql-bkup
|
||||
container_name: mysql-bkup
|
||||
command: backup -d database
|
||||
volumes:
|
||||
- ./backup:/backup
|
||||
- ./public_key.asc:/config/public_key.asc
|
||||
environment:
|
||||
- DB_PORT=3306
|
||||
- DB_HOST=mysql
|
||||
- DB_NAME=database
|
||||
- DB_USER=username
|
||||
- DB_PASSWORD=password
|
||||
## Required to encrypt backup
|
||||
- GPG_PUBLIC_KEY=/config/public_key.asc
|
||||
# Ensure the pg-bkup container is connected to the same network as your database
|
||||
networks:
|
||||
- web
|
||||
|
||||
networks:
|
||||
web:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Manual Decryption
|
||||
|
||||
If you encrypted your backup using a GPG public key, you must manually decrypt it before restoration. Use the `gnupg` tool for decryption.
|
||||
|
||||
### Decrypt Using a Passphrase
|
||||
|
||||
```bash
|
||||
gpg --batch --passphrase "my-passphrase" \
|
||||
--output database_20240730_044201.sql.gz \
|
||||
--decrypt database_20240730_044201.sql.gz.gpg
|
||||
```
|
||||
|
||||
### Decrypt Using a Private Key
|
||||
|
||||
```bash
|
||||
gpg --output database_20240730_044201.sql.gz \
|
||||
--decrypt database_20240730_044201.sql.gz.gpg
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Notes
|
||||
|
||||
- **Automatic Restoration**: Backups encrypted with a GPG passphrase can be restored directly without manual decryption.
|
||||
- **Private key restore**: Backups encrypted with a GPG public key restore directly when `GPG_PRIVATE_KEY` is set; manual decryption also works.
|
||||
- **Fail closed**: if `GPG_PUBLIC_KEY` (or any configured key) cannot be read, the backup fails instead of being stored unencrypted.
|
||||
- **Security**: Always keep your GPG passphrase and private key secure. Use Kubernetes Secrets or other secure methods to manage sensitive data.
|
||||
|
||||
---
|
||||
|
||||
## Using age
|
||||
|
||||
[age](https://age-encryption.org) is a modern alternative to GPG. Set `AGE_RECIPIENTS` (one or more `age1...` or `ssh-ed25519 ...` public keys, comma or newline separated, or `AGE_RECIPIENTS_FILE`) and backups are saved as `.sql.gz.age`. Restore with the matching identity in `AGE_IDENTITY` (`AGE-SECRET-KEY-...`) or `AGE_IDENTITY_FILE` (default `/config/age/identity.txt`).
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- AGE_RECIPIENTS=age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
|
||||
# restore:
|
||||
# - AGE_IDENTITY_FILE=/run/secrets/age_identity
|
||||
```
|
||||
|
||||
`AGE_PASSPHRASE` encrypts with a passphrase instead (it needs about 256 MiB of memory for the key derivation). `ENCRYPTION=auto|none|gpg|age` chooses explicitly; with `auto` (default) GPG wins when both GPG and age keys are set. Decrypt by hand with `age -d -i key.txt file.sql.gz.age | gunzip`.
|
||||
+59
-81
@@ -2,130 +2,108 @@
|
||||
title: Migrate database
|
||||
layout: default
|
||||
parent: How Tos
|
||||
nav_order: 9
|
||||
nav_order: 10
|
||||
---
|
||||
|
||||
# Migrate database
|
||||
# Migrate Database
|
||||
|
||||
To migrate the database, you need to add `migrate` command.
|
||||
To migrate a MySQL database from a source to a target database, you can use the `migrate` command. This feature simplifies the process by combining the backup and restore operations into a single step.
|
||||
|
||||
{: .note }
|
||||
The Mysql backup has another great feature: migrating your database from a source database to a target.
|
||||
|
||||
As you know, to restore a database from a source to a target database, you need 2 operations: which is to start by backing up the source database and then restoring the source backed database to the target database.
|
||||
Instead of proceeding like that, you can use the integrated feature `(migrate)`, which will help you migrate your database by doing only one operation.
|
||||
The `migrate` command eliminates the need for separate backup and restore operations. It directly transfers data from the source database to the target database.
|
||||
|
||||
{: .warning }
|
||||
The `migrate` operation is irreversible, please backup your target database before this action.
|
||||
The `migrate` operation is **irreversible**. Always back up your target database before performing this action.
|
||||
|
||||
### Docker compose
|
||||
```yml
|
||||
---
|
||||
|
||||
## Configuration Steps
|
||||
|
||||
1. **Source Database**: Provide connection details for the source database.
|
||||
2. **Target Database**: Provide connection details for the target database.
|
||||
3. **Run the Migration**: Use the `migrate` command to initiate the migration.
|
||||
|
||||
---
|
||||
|
||||
## Example: Docker Compose Configuration
|
||||
|
||||
Below is an example `docker-compose.yml` configuration for migrating a database:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mysql-bkup:
|
||||
# In production, it is advised to lock your image tag to a proper
|
||||
# release version instead of using `latest`.
|
||||
# Check https://github.com/jkaninda/mysql-bkup/releases
|
||||
# for a list of available releases.
|
||||
# In production, lock your image tag to a specific release version
|
||||
# instead of using `latest`. Check https://github.com/jkaninda/mysqlbkup/releases
|
||||
# for available releases.
|
||||
image: jkaninda/mysql-bkup
|
||||
container_name: mysql-bkup
|
||||
command: migrate
|
||||
volumes:
|
||||
- ./backup:/backup
|
||||
environment:
|
||||
## Source database
|
||||
## Source Database
|
||||
- DB_PORT=3306
|
||||
- DB_HOST=mysql
|
||||
- DB_NAME=database
|
||||
- DB_USERNAME=username
|
||||
- DB_USER=username
|
||||
- DB_PASSWORD=password
|
||||
## Target database
|
||||
|
||||
## Target Database
|
||||
- TARGET_DB_HOST=target-mysql
|
||||
- TARGET_DB_PORT=3306
|
||||
- TARGET_DB_NAME=dbname
|
||||
- TARGET_DB_USERNAME=username
|
||||
- TARGET_DB_USER=username
|
||||
- TARGET_DB_PASSWORD=password
|
||||
# mysql-bkup container must be connected to the same network with your database
|
||||
|
||||
# Ensure the mysql-bkup container is connected to the same network as your database
|
||||
networks:
|
||||
- web
|
||||
|
||||
networks:
|
||||
web:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Migrate database using Docker CLI
|
||||
## Migrate Database Using Docker CLI
|
||||
|
||||
You can also run the migration directly using the Docker CLI. Below is an example:
|
||||
|
||||
```
|
||||
## Source database
|
||||
DB_HOST=mysql
|
||||
### Environment Variables
|
||||
|
||||
Save your source and target database connection details in an environment file (e.g., `your-env`):
|
||||
|
||||
```bash
|
||||
## Source Database
|
||||
DB_HOST=postgres
|
||||
DB_PORT=3306
|
||||
DB_NAME=dbname
|
||||
DB_USERNAME=username
|
||||
DB_USER=username
|
||||
DB_PASSWORD=password
|
||||
|
||||
## Taget database
|
||||
## Target Database
|
||||
TARGET_DB_HOST=target-mysql
|
||||
TARGET_DB_PORT=3306
|
||||
TARGET_DB_NAME=dbname
|
||||
TARGET_DB_USERNAME=username
|
||||
TARGET_DB_USER=username
|
||||
TARGET_DB_PASSWORD=password
|
||||
```
|
||||
|
||||
```shell
|
||||
docker run --rm --network your_network_name \
|
||||
--env-file your-env
|
||||
-v $PWD/backup:/backup/ \
|
||||
jkaninda/mysql-bkup migrate
|
||||
### Run the Migration
|
||||
|
||||
```bash
|
||||
docker run --rm --network your_network_name \
|
||||
--env-file your-env \
|
||||
-v $PWD/backup:/backup/ \
|
||||
jkaninda/mysql-bkup migrate
|
||||
```
|
||||
|
||||
## Kubernetes
|
||||
---
|
||||
|
||||
```yaml
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: migrate-db
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 100
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: mysql-bkup
|
||||
# In production, it is advised to lock your image tag to a proper
|
||||
# release version instead of using `latest`.
|
||||
# Check https://github.com/jkaninda/mysql-bkup/releases
|
||||
# for a list of available releases.
|
||||
image: jkaninda/mysql-bkup
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- migrate
|
||||
resources:
|
||||
limits:
|
||||
memory: "128Mi"
|
||||
cpu: "500m"
|
||||
env:
|
||||
## Source Database
|
||||
- name: DB_HOST
|
||||
value: "mysql"
|
||||
- name: DB_PORT
|
||||
value: "3306"
|
||||
- name: DB_NAME
|
||||
value: "dbname"
|
||||
- name: DB_USERNAME
|
||||
value: "username"
|
||||
- name: DB_PASSWORD
|
||||
value: "password"
|
||||
## Target Database
|
||||
- name: TARGET_DB_HOST
|
||||
value: "target-mysql"
|
||||
- name: TARGET_DB_PORT
|
||||
value: "3306"
|
||||
- name: TARGET_DB_NAME
|
||||
value: "dbname"
|
||||
- name: TARGET_DB_USERNAME
|
||||
value: "username"
|
||||
- name: TARGET_DB_PASSWORD
|
||||
value: "password"
|
||||
restartPolicy: Never
|
||||
```
|
||||
## Key Notes
|
||||
|
||||
- **Streaming**: the dump is piped straight into the target server; no file is written. The target database is created if it does not exist, and `TARGET_DB_NAME` defaults to the source name.
|
||||
- **All databases**: `migrate --all-databases` copies every database (system databases excluded, `--exclude-db` / `BACKUP_EXCLUDE_DATABASES` honoured) under the same names.
|
||||
- **MySQL and MariaDB**: source and target may be different flavours; each side uses its own client. Objects that only exist in one flavour (for example MySQL-only collations on old MariaDB versions) can make a cross-flavour migration fail.
|
||||
- **Irreversible Operation**: The `migrate` command directly transfers data from the source to the target database. Ensure you have a backup of the target database before proceeding.
|
||||
- **Network Configuration**: Ensure the `mysql-bkup` container is connected to the same network as your source and target databases.
|
||||
@@ -0,0 +1,103 @@
|
||||
---
|
||||
title: Run multiple database backup schedules in the same container
|
||||
layout: default
|
||||
parent: How Tos
|
||||
nav_order: 11
|
||||
---
|
||||
|
||||
|
||||
# Multiple Backup Schedules
|
||||
|
||||
This tool supports running multiple database backup schedules within the same container.
|
||||
You can configure these schedules with different settings using a **configuration file**. This flexibility allows you to manage backups for multiple databases efficiently.
|
||||
|
||||
---
|
||||
|
||||
## Configuration File Setup
|
||||
|
||||
The configuration file can be mounted into the container at `/config/config.yaml`, `/config/config.yml`, or specified via the `BACKUP_CONFIG_FILE` environment variable.
|
||||
|
||||
### Key Features:
|
||||
- **Global Environment Variables**: Use these for databases that share the same configuration.
|
||||
- **Database-Specific Overrides**: Override global settings for individual databases by specifying them in the configuration file or using the database name as a prefix or suffix in the variable name (e.g., `DB_HOST_DATABASENAME` or `DATABASENAME_DB_HOST`).
|
||||
- **Global Cron Expression**: Define a global `cronExpression` in the configuration file to schedule backups for all databases. If omitted, backups will run immediately.
|
||||
- **Configuration File Path**: Specify the configuration file path using:
|
||||
- The `BACKUP_CONFIG_FILE` environment variable.
|
||||
- The `--config` or `-c` flag for the backup command.
|
||||
|
||||
---
|
||||
|
||||
## Configuration File Example
|
||||
|
||||
Below is an example configuration file (`config.yaml`) that defines multiple databases and their respective backup settings:
|
||||
|
||||
```yaml
|
||||
# Optional: Define a global cron expression for scheduled backups.
|
||||
# Example: "@every 20m" (runs every 20 minutes). If omitted, backups run immediately.
|
||||
cronExpression: "" # Optional: Define a global cron expression for scheduled backups.
|
||||
backupRescueMode: false # Optional: Set to true to enable rescue mode for backups.
|
||||
databases:
|
||||
- host: mysql1 # Optional: Overrides DB_HOST or uses DB_HOST_DATABASE1.
|
||||
port: 3306 # Optional: Default is 5432. Overrides DB_PORT or uses DB_PORT_DATABASE1.
|
||||
name: database1 # Required: Database name.
|
||||
user: database1 # Optional: Overrides DB_USER or uses DB_USER_DATABASE1.
|
||||
password: password # Optional: Overrides DB_PASSWORD or uses DB_PASSWORD_DATABASE1.
|
||||
path: /s3-path/database1 # Required: Backup path for SSH, FTP, or S3 (e.g., /home/toto/backup/).
|
||||
|
||||
- host: mysql2 # Optional: Overrides DB_HOST or uses DB_HOST_LLAP.
|
||||
port: 3306 # Optional: Default is 5432. Overrides DB_PORT or uses DB_PORT_LLAP.
|
||||
name: lldap # Required: Database name.
|
||||
user: lldap # Optional: Overrides DB_USER or uses DB_USER_LLAP.
|
||||
password: password # Optional: Overrides DB_PASSWORD or uses DB_PASSWORD_LLAP.
|
||||
path: /s3-path/lldap # Required: Backup path for SSH, FTP, or S3 (e.g., /home/toto/backup/).
|
||||
|
||||
- host: mysql3 # Optional: Overrides DB_HOST or uses DB_HOST_KEYCLOAK.
|
||||
port: 3306 # Optional: Default is 5432. Overrides DB_PORT or uses DB_PORT_KEYCLOAK.
|
||||
name: keycloak # Required: Database name.
|
||||
user: keycloak # Optional: Overrides DB_USER or uses DB_USER_KEYCLOAK.
|
||||
password: password # Optional: Overrides DB_PASSWORD or uses DB_PASSWORD_KEYCLOAK.
|
||||
path: /s3-path/keycloak # Required: Backup path for SSH, FTP, or S3 (e.g., /home/toto/backup/).
|
||||
|
||||
- host: mysql4 # Optional: Overrides DB_HOST or uses DB_HOST_JOPLIN.
|
||||
port: 3306 # Optional: Default is 5432. Overrides DB_PORT or uses DB_PORT_JOPLIN.
|
||||
name: joplin # Required: Database name.
|
||||
user: joplin # Optional: Overrides DB_USER or uses DB_USER_JOPLIN.
|
||||
password: password # Optional: Overrides DB_PASSWORD or uses DB_PASSWORD_JOPLIN.
|
||||
path: /s3-path/joplin # Required: Backup path for SSH, FTP, or S3 (e.g., /home/toto/backup/).
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docker Compose Configuration
|
||||
|
||||
To use the configuration file in a Docker Compose setup, mount the file and specify its path using the `BACKUP_CONFIG_FILE` environment variable.
|
||||
|
||||
### Example: Docker Compose File
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mysql-bkup:
|
||||
# In production, lock your image tag to a specific release version
|
||||
# instead of using `latest`. Check https://github.com/jkaninda/mysql-bkup/releases
|
||||
# for available releases.
|
||||
image: jkaninda/mysql-bkup
|
||||
container_name: mysql-bkup
|
||||
command: backup #--config /backup/config.yaml # config file
|
||||
volumes:
|
||||
- ./backup:/backup # Mount the backup directory
|
||||
- ./config.yaml:/backup/config.yaml # Mount the configuration file
|
||||
environment:
|
||||
## Specify the path to the configuration file
|
||||
- BACKUP_CONFIG_FILE=/backup/config.yaml
|
||||
# Ensure the mysql-bkup container is connected to the same network as your database
|
||||
networks:
|
||||
- web
|
||||
|
||||
networks:
|
||||
web:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
---
|
||||
title: Receive notifications
|
||||
layout: default
|
||||
parent: How Tos
|
||||
nav_order: 13
|
||||
---
|
||||
|
||||
# Receive Notifications
|
||||
|
||||
You can configure the system to send email (SMTP or Posta) or Telegram notifications when a backup succeeds or fails.
|
||||
|
||||
This section explains how to set up and customize notifications.
|
||||
|
||||
---
|
||||
|
||||
## Email Notifications
|
||||
|
||||
To send email notifications, provide SMTP credentials, a sender address, and recipient addresses. Notifications will be sent for both successful and failed backup runs.
|
||||
|
||||
### Example: Email Notification Configuration
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mysql-bkup:
|
||||
image: jkaninda/mysql-bkup
|
||||
container_name: mysql-bkup
|
||||
command: backup
|
||||
volumes:
|
||||
- ./backup:/backup
|
||||
environment:
|
||||
- DB_PORT=3306
|
||||
- DB_HOST=mysql
|
||||
- DB_NAME=database
|
||||
- DB_USER=username
|
||||
- DB_PASSWORD=password
|
||||
## SMTP Configuration
|
||||
- MAIL_HOST=smtp.example.com
|
||||
- MAIL_PORT=587
|
||||
- MAIL_USERNAME=your-email@example.com
|
||||
- MAIL_PASSWORD=your-email-password
|
||||
- MAIL_FROM=Backup Jobs <backup@example.com>
|
||||
## Multiple recipients separated by a comma
|
||||
- MAIL_TO=me@example.com,team@example.com,manager@example.com
|
||||
## Only for SMTP servers with a self-signed certificate
|
||||
#- MAIL_INSECURE_SKIP_VERIFY=true
|
||||
## Time format for notifications
|
||||
- TIME_FORMAT=2006-01-02 at 15:04:05
|
||||
## Backup reference (e.g., database/cluster name or server name)
|
||||
- BACKUP_REFERENCE=database/Paris cluster
|
||||
networks:
|
||||
- web
|
||||
|
||||
networks:
|
||||
web:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Posta Notifications
|
||||
|
||||
[Posta](https://github.com/goposta/posta) can deliver the notification emails instead of an SMTP server. Set the Posta URL, an API key with the `send` scope and the recipients. By default the same HTML email is sent (with a plain-text version). To use a template stored in Posta instead, set `POSTA_TEMPLATE` (and optionally `POSTA_TEMPLATE_ERROR` for failures); the notification fields (`database`, `file`, `duration`, `storage`, `location`, `size`, `backup_size`, `sha256`, `encryption`, `error`, `time`, `success`, `tool`...) are passed as template data.
|
||||
|
||||
### Example: Posta Notification Configuration
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mysql-bkup:
|
||||
image: jkaninda/mysql-bkup
|
||||
container_name: mysql-bkup
|
||||
command: backup
|
||||
volumes:
|
||||
- ./backup:/backup
|
||||
environment:
|
||||
- DB_PORT=3306
|
||||
- DB_HOST=db
|
||||
- DB_NAME=database
|
||||
- DB_USER=username
|
||||
- DB_PASSWORD=password
|
||||
## Posta
|
||||
- POSTA_URL=https://posta.example.com
|
||||
- POSTA_API_KEY=psk_your_api_key
|
||||
- POSTA_FROM=Backup Jobs <backup@example.com>
|
||||
## Multiple recipients separated by a comma (default MAIL_TO)
|
||||
- POSTA_TO=me@example.com,team@example.com
|
||||
## Optional: templates stored in Posta
|
||||
#- POSTA_TEMPLATE=backup-success
|
||||
#- POSTA_TEMPLATE_ERROR=backup-failure
|
||||
#- POSTA_LANGUAGE=en
|
||||
## Only for account-wide API keys
|
||||
#- POSTA_WORKSPACE_ID=42
|
||||
## Only notify failures
|
||||
#- NOTIFY_ON=failure
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Telegram Notifications
|
||||
|
||||
To send Telegram notifications, provide your bot token and chat ID. Notifications will be sent for both successful and failed backup runs.
|
||||
|
||||
### Example: Telegram Notification Configuration
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mysql-bkup:
|
||||
image: jkaninda/mysql-bkup
|
||||
container_name: mysql-bkup
|
||||
command: backup
|
||||
volumes:
|
||||
- ./backup:/backup
|
||||
environment:
|
||||
- DB_PORT=3306
|
||||
- DB_HOST=mysql
|
||||
- DB_NAME=database
|
||||
- DB_USER=username
|
||||
- DB_PASSWORD=password
|
||||
## Telegram Configuration
|
||||
- TG_TOKEN=[BOT ID]:[BOT TOKEN]
|
||||
- TG_CHAT_ID=your-chat-id
|
||||
## Time format for notifications
|
||||
- TIME_FORMAT=2006-01-02 at 15:04:05
|
||||
## Backup reference (e.g., database/cluster name or server name)
|
||||
- BACKUP_REFERENCE=database/Paris cluster
|
||||
networks:
|
||||
- web
|
||||
|
||||
networks:
|
||||
web:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Customize Notifications
|
||||
|
||||
You can customize the title and body of notifications using Go templates. Template files must be mounted inside the container at `/config/templates`. The following templates are supported:
|
||||
|
||||
- `email.tmpl`: Template for successful email notifications.
|
||||
- `telegram.tmpl`: Template for successful Telegram notifications.
|
||||
- `email-error.tmpl`: Template for failed email notifications.
|
||||
- `telegram-error.tmpl`: Template for failed Telegram notifications.
|
||||
|
||||
### Template Data
|
||||
|
||||
The following data is passed to the templates:
|
||||
|
||||
- `Database`: Database name.
|
||||
- `StartTime`: Backup start time.
|
||||
- `EndTime`: Backup end time.
|
||||
- `Storage`: Backup storage type (e.g., local, S3, SSH).
|
||||
- `BackupLocation`: Backup file location.
|
||||
- `BackupSize`: Backup file size in bytes.
|
||||
- `BackupReference`: Backup reference (e.g., database/cluster name or server name).
|
||||
- `Error`: Error message (only for error templates).
|
||||
|
||||
---
|
||||
|
||||
### Example Templates
|
||||
|
||||
#### `email.tmpl` (Successful Backup)
|
||||
|
||||
```html
|
||||
<h2>Hi,</h2>
|
||||
<p>Backup of the {{.Database}} database has been successfully completed on {{.EndTime}}.</p>
|
||||
<h3>Backup Details:</h3>
|
||||
<ul>
|
||||
<li>Database Name: {{.Database}}</li>
|
||||
<li>Backup Start Time: {{.StartTime}}</li>
|
||||
<li>Backup End Time: {{.EndTime}}</li>
|
||||
<li>Backup Storage: {{.Storage}}</li>
|
||||
<li>Backup Location: {{.BackupLocation}}</li>
|
||||
<li>Backup Size: {{.BackupSize}} bytes</li>
|
||||
<li>Backup Reference: {{.BackupReference}}</li>
|
||||
</ul>
|
||||
<p>Best regards,</p>
|
||||
```
|
||||
|
||||
#### `telegram.tmpl` (Successful Backup)
|
||||
|
||||
```html
|
||||
✅ Database Backup Notification – {{.Database}}
|
||||
Hi,
|
||||
Backup of the {{.Database}} database has been successfully completed on {{.EndTime}}.
|
||||
|
||||
Backup Details:
|
||||
- Database Name: {{.Database}}
|
||||
- Backup Start Time: {{.StartTime}}
|
||||
- Backup End Time: {{.EndTime}}
|
||||
- Backup Storage: {{.Storage}}
|
||||
- Backup Location: {{.BackupLocation}}
|
||||
- Backup Size: {{.BackupSize}} bytes
|
||||
- Backup Reference: {{.BackupReference}}
|
||||
```
|
||||
|
||||
#### `email-error.tmpl` (Failed Backup)
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>🔴 Urgent: Database Backup Failure Notification</title>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Hi,</h2>
|
||||
<p>An error occurred during database backup.</p>
|
||||
<h3>Failure Details:</h3>
|
||||
<ul>
|
||||
<li>Error Message: {{.Error}}</li>
|
||||
<li>Date: {{.EndTime}}</li>
|
||||
<li>Backup Reference: {{.BackupReference}}</li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
#### `telegram-error.tmpl` (Failed Backup)
|
||||
|
||||
```html
|
||||
🔴 Urgent: Database Backup Failure Notification
|
||||
|
||||
An error occurred during database backup.
|
||||
Failure Details:
|
||||
|
||||
Error Message: {{.Error}}
|
||||
Date: {{.EndTime}}
|
||||
Backup Reference: {{.BackupReference}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Notes
|
||||
|
||||
- **SMTP Configuration**: Ensure your SMTP server supports TLS unless `MAIL_INSECURE_SKIP_VERIFY` is set to `true`. `MAIL_TLS=starttls|tls|none` selects the TLS mode. The 1.x variable `MAIL_SKIP_TLS` is still read with its documented meaning (`true` = skip certificate verification); 1.x had it inverted. Slack (`SLACK_WEBHOOK_URL`), Discord (`DISCORD_WEBHOOK_URL`), ntfy (`NTFY_URL`), generic webhooks (`WEBHOOK_URL`, `WEBHOOK_SECRET`) and healthchecks.io pings (`HEALTHCHECK_PING_URL`) are also available; `NOTIFY_ON=always|failure|success` filters them.
|
||||
- **Telegram Configuration**: Obtain your bot token and chat ID from Telegram.
|
||||
- **Custom Templates**: Mount custom templates to `/config/templates` to override default notifications.
|
||||
- **Time Format**: Use the `TIME_FORMAT` environment variable to customize the timestamp format in notifications.
|
||||
@@ -2,94 +2,74 @@
|
||||
title: Restore database from AWS S3
|
||||
layout: default
|
||||
parent: How Tos
|
||||
nav_order: 5
|
||||
nav_order: 6
|
||||
---
|
||||
|
||||
# Restore database from S3 storage
|
||||
# Restore Database from S3 Storage
|
||||
|
||||
To restore the database, you need to add `restore` command and specify the file to restore by adding `--file store_20231219_022941.sql.gz`.
|
||||
To restore a MySQL database from a backup stored in S3, use the `restore` command and specify the backup file with the `--file` flag. The system supports the following file formats:
|
||||
|
||||
{: .note }
|
||||
It supports __.sql__ and __.sql.gz__ compressed file.
|
||||
- `.sql` (uncompressed SQL dump)
|
||||
- `.sql.gz` (gzip-compressed SQL dump)
|
||||
- `.sql.gpg` (GPG-encrypted SQL dump)
|
||||
- `.sql.gz.gpg` (GPG-encrypted and gzip-compressed SQL dump)
|
||||
|
||||
### Restore
|
||||
---
|
||||
|
||||
```yml
|
||||
## Configuration Steps
|
||||
|
||||
1. **Specify the Backup File**: Use the `--file` flag to specify the backup file to restore.
|
||||
2. **Set the Storage Type**: Add the `--storage s3` flag to indicate that the backup is stored in S3.
|
||||
3. **Provide S3 Configuration**: Include the necessary AWS S3 credentials and configuration.
|
||||
4. **Provide Database Credentials**: Ensure the correct database connection details are provided.
|
||||
|
||||
---
|
||||
|
||||
## Example: Restore from S3 Configuration
|
||||
|
||||
Below is an example `docker-compose.yml` configuration for restoring a database from S3 storage:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mysql-bkup:
|
||||
# In production, it is advised to lock your image tag to a proper
|
||||
# release version instead of using `latest`.
|
||||
# Check https://github.com/jkaninda/mysql-bkup/releases
|
||||
# for a list of available releases.
|
||||
# In production, lock your image tag to a specific release version
|
||||
# instead of using `latest`. Check https://github.com/jkaninda/mysql-bkup/releases
|
||||
# for available releases.
|
||||
image: jkaninda/mysql-bkup
|
||||
container_name: mysql-bkup
|
||||
command: restore --storage s3 -d my-database -f store_20231219_022941.sql.gz --path /my-custom-path
|
||||
volumes:
|
||||
- ./backup:/backup
|
||||
- ./backup:/backup # Mount the directory for local operations (if needed)
|
||||
environment:
|
||||
- DB_PORT=3306
|
||||
- DB_HOST=mysql
|
||||
- DB_NAME=database
|
||||
- DB_USERNAME=username
|
||||
- DB_USER=username
|
||||
- DB_PASSWORD=password
|
||||
## AWS configurations
|
||||
## AWS S3 Configuration
|
||||
- AWS_S3_ENDPOINT=https://s3.amazonaws.com
|
||||
- AWS_S3_BUCKET_NAME=backup
|
||||
- AWS_REGION="us-west-2"
|
||||
- AWS_REGION=us-west-2
|
||||
- AWS_ACCESS_KEY=xxxx
|
||||
- AWS_SECRET_KEY=xxxxx
|
||||
## In case you are using S3 alternative such as Minio and your Minio instance is not secured, you change it to true
|
||||
- AWS_DISABLE_SSL="false"
|
||||
# mysql-bkup container must be connected to the same network with your database
|
||||
## Optional: Disable SSL for S3 alternatives like Minio
|
||||
- AWS_DISABLE_SSL=false
|
||||
## Optional: Enable path-style access for S3 alternatives like Minio
|
||||
- AWS_FORCE_PATH_STYLE=false
|
||||
# Ensure the mysql-bkup container is connected to the same network as your database
|
||||
networks:
|
||||
- web
|
||||
|
||||
networks:
|
||||
web:
|
||||
```
|
||||
|
||||
## Restore on Kubernetes
|
||||
---
|
||||
|
||||
Simple Kubernetes restore Job:
|
||||
## Key Notes
|
||||
|
||||
```yaml
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: restore-db
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: mysql-bkup
|
||||
image: jkaninda/mysql-bkup
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- restore -s s3 --path /custom_path -f store_20231219_022941.sql.gz
|
||||
env:
|
||||
- name: DB_PORT
|
||||
value: "3306"
|
||||
- name: DB_HOST
|
||||
value: ""
|
||||
- name: DB_NAME
|
||||
value: ""
|
||||
- name: DB_USERNAME
|
||||
value: ""
|
||||
# Please use secret!
|
||||
- name: DB_PASSWORD
|
||||
value: ""
|
||||
- name: AWS_S3_ENDPOINT
|
||||
value: "https://s3.amazonaws.com"
|
||||
- name: AWS_S3_BUCKET_NAME
|
||||
value: "xxx"
|
||||
- name: AWS_REGION
|
||||
value: "us-west-2"
|
||||
- name: AWS_ACCESS_KEY
|
||||
value: "xxxx"
|
||||
- name: AWS_SECRET_KEY
|
||||
value: "xxxx"
|
||||
- name: AWS_DISABLE_SSL
|
||||
value: "false"
|
||||
restartPolicy: Never
|
||||
backoffLimit: 4
|
||||
```
|
||||
- **Supported File Formats**: The restore process supports `.sql`, `.sql.gz`, `.sql.gpg`, and `.sql.gz.gpg` files.
|
||||
- **S3 Path**: Use the `--path` flag to specify the folder within the S3 bucket where the backup file is located.
|
||||
- **Encrypted Backups**: If the backup is encrypted with GPG, ensure the `GPG_PASSPHRASE` environment variable is set for automatic decryption.
|
||||
- **S3 Alternatives**: For S3-compatible storage like Minio, set `AWS_DISABLE_SSL` and `AWS_FORCE_PATH_STYLE` as needed.
|
||||
- **Network Configuration**: Ensure the `pg-bkup` container is connected to the same network as your database.
|
||||
@@ -2,92 +2,74 @@
|
||||
title: Restore database from SSH
|
||||
layout: default
|
||||
parent: How Tos
|
||||
nav_order: 6
|
||||
nav_order: 7
|
||||
---
|
||||
# Restore database from SSH remote server
|
||||
|
||||
To restore the database from your remote server, you need to add `restore` command and specify the file to restore by adding `--file store_20231219_022941.sql.gz`.
|
||||
# Restore Database from SSH Remote Server
|
||||
|
||||
{: .note }
|
||||
It supports __.sql__ and __.sql.gz__ compressed file.
|
||||
To restore a MySQL database from a backup stored on an SSH remote server, use the `restore` command and specify the backup file with the `--file` flag. The system supports the following file formats:
|
||||
|
||||
### Restore
|
||||
- `.sql` (uncompressed SQL dump)
|
||||
- `.sql.gz` (gzip-compressed SQL dump)
|
||||
- `.sql.gpg` (GPG-encrypted SQL dump)
|
||||
- `.sql.gz.gpg` (GPG-encrypted and gzip-compressed SQL dump)
|
||||
|
||||
```yml
|
||||
---
|
||||
|
||||
## Configuration Steps
|
||||
|
||||
1. **Specify the Backup File**: Use the `--file` flag to specify the backup file to restore.
|
||||
2. **Set the Storage Type**: Add the `--storage ssh` flag to indicate that the backup is stored on an SSH remote server.
|
||||
3. **Provide SSH Configuration**: Include the necessary SSH credentials and configuration.
|
||||
4. **Provide Database Credentials**: Ensure the correct database connection details are provided.
|
||||
|
||||
---
|
||||
|
||||
## Example: Restore from SSH Remote Server Configuration
|
||||
|
||||
Below is an example `docker-compose.yml` configuration for restoring a database from an SSH remote server:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mysql-bkup:
|
||||
# In production, it is advised to lock your image tag to a proper
|
||||
# release version instead of using `latest`.
|
||||
# Check https://github.com/jkaninda/mysql-bkup/releases
|
||||
# for a list of available releases.
|
||||
# In production, lock your image tag to a specific release version
|
||||
# instead of using `latest`. Check https://github.com/jkaninda/mysql-bkup/releases
|
||||
# for available releases.
|
||||
image: jkaninda/mysql-bkup
|
||||
container_name: mysql-bkup
|
||||
command: restore --storage ssh -d my-database -f store_20231219_022941.sql.gz --path /home/jkaninda/backups
|
||||
volumes:
|
||||
- ./backup:/backup
|
||||
- ./backup:/backup # Mount the directory for local operations (if needed)
|
||||
- ./id_ed25519:/tmp/id_ed25519 # Mount the SSH private key file
|
||||
environment:
|
||||
- DB_PORT=3306
|
||||
- DB_HOST=postgres
|
||||
- DB_HOST=mysql
|
||||
- DB_NAME=database
|
||||
- DB_USERNAME=username
|
||||
- DB_USER=username
|
||||
- DB_PASSWORD=password
|
||||
## SSH config
|
||||
- SSH_HOST_NAME="hostname"
|
||||
- SSH_PORT=22
|
||||
- SSH_USER=user
|
||||
- SSH_REMOTE_PATH=/home/jkaninda/backups
|
||||
- SSH_IDENTIFY_FILE=/tmp/id_ed25519
|
||||
## We advise you to use a private jey instead of password
|
||||
#- SSH_PASSWORD=password
|
||||
# mysql-bkup container must be connected to the same network with your database
|
||||
## SSH Configuration
|
||||
- SFTP_HOST=hostname
|
||||
- SFTP_PORT=22
|
||||
- SFTP_USER=user
|
||||
- REMOTE_PATH=/home/jkaninda/backups
|
||||
- SFTP_IDENTITY_FILE=/tmp/id_ed25519
|
||||
- SFTP_KNOWN_HOSTS=/config/known_hosts
|
||||
## Optional: Use password instead of private key (not recommended)
|
||||
#- SFTP_PASSWORD=password
|
||||
# Ensure the mysql-bkup container is connected to the same network as your database
|
||||
networks:
|
||||
- web
|
||||
|
||||
networks:
|
||||
web:
|
||||
```
|
||||
## Restore on Kubernetes
|
||||
|
||||
Simple Kubernetes restore Job:
|
||||
---
|
||||
|
||||
```yaml
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: restore-db
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: mysql-bkup
|
||||
image: jkaninda/mysql-bkup
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- restore -s ssh -f store_20231219_022941.sql.gz
|
||||
env:
|
||||
- name: DB_PORT
|
||||
value: "3306"
|
||||
- name: DB_HOST
|
||||
value: ""
|
||||
- name: DB_NAME
|
||||
value: ""
|
||||
- name: DB_USERNAME
|
||||
value: ""
|
||||
# Please use secret!
|
||||
- name: DB_PASSWORD
|
||||
value: ""
|
||||
- name: SSH_HOST_NAME
|
||||
value: ""
|
||||
- name: SSH_PORT
|
||||
value: "22"
|
||||
- name: SSH_USER
|
||||
value: "xxx"
|
||||
- name: SSH_REMOTE_PATH
|
||||
value: "/home/jkaninda/backups"
|
||||
- name: AWS_ACCESS_KEY
|
||||
value: "xxxx"
|
||||
- name: SSH_IDENTIFY_FILE
|
||||
value: "/tmp/id_ed25519"
|
||||
restartPolicy: Never
|
||||
backoffLimit: 4
|
||||
```
|
||||
## Key Notes
|
||||
|
||||
- **Supported File Formats**: The restore process supports `.sql`, `.sql.gz`, `.sql.gpg`, and `.sql.gz.gpg` files.
|
||||
- **SSH Path**: Use the `--path` flag to specify the folder on the SSH remote server where the backup file is located.
|
||||
- **Encrypted Backups**: If the backup is encrypted with GPG, ensure the `GPG_PASSPHRASE` environment variable is set for automatic decryption.
|
||||
- **SSH Authentication**: Use a private key (`SFTP_IDENTITY_FILE`) for SSH authentication instead of a password for better security. Host keys are verified against `SFTP_KNOWN_HOSTS` (or set `SFTP_INSECURE_IGNORE_HOST_KEY=true`).
|
||||
- **Network Configuration**: Ensure the `mysql-bkup` container is connected to the same network as your database.
|
||||
+40
-16
@@ -2,39 +2,63 @@
|
||||
title: Restore database
|
||||
layout: default
|
||||
parent: How Tos
|
||||
nav_order: 4
|
||||
nav_order: 5
|
||||
---
|
||||
|
||||
# Restore database
|
||||
|
||||
To restore the database, you need to add `restore` command and specify the file to restore by adding `--file store_20231219_022941.sql.gz`.
|
||||
# Restore Database
|
||||
|
||||
{: .note }
|
||||
It supports __.sql__ and __.sql.gz__ compressed file.
|
||||
To restore a MySQL database, use the `restore` command and specify the backup file to restore with the `--file` flag.
|
||||
|
||||
### Restore
|
||||
The system supports the following file formats:
|
||||
|
||||
```yml
|
||||
- `.sql` (uncompressed SQL dump)
|
||||
- `.sql.gz` (gzip-compressed SQL dump)
|
||||
- `.sql.gpg` (GPG-encrypted SQL dump)
|
||||
- `.sql.gz.gpg` (GPG-encrypted and gzip-compressed SQL dump)
|
||||
|
||||
---
|
||||
|
||||
## Configuration Steps
|
||||
|
||||
1. **Specify the Backup File**: Use the `--file` flag to specify the backup file to restore.
|
||||
2. **Provide Database Credentials**: Ensure the correct database connection details are provided.
|
||||
|
||||
---
|
||||
|
||||
## Example: Restore Configuration
|
||||
|
||||
Below is an example `docker-compose.yml` configuration for restoring a database:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mysql-bkup:
|
||||
# In production, it is advised to lock your image tag to a proper
|
||||
# release version instead of using `latest`.
|
||||
# Check https://github.com/jkaninda/mysql-bkup/releases
|
||||
# for a list of available releases.
|
||||
# In production, lock your image tag to a specific release version
|
||||
# instead of using `latest`. Check https://github.com/jkaninda/mysql-bkup/releases
|
||||
# for available releases.
|
||||
image: jkaninda/mysql-bkup
|
||||
container_name: mysql-bkup
|
||||
command: restore -d database -f store_20231219_022941.sql.gz
|
||||
volumes:
|
||||
- ./backup:/backup
|
||||
- ./backup:/backup # Mount the directory containing the backup file
|
||||
environment:
|
||||
- DB_PORT=3306
|
||||
- DB_HOST=mysql
|
||||
- DB_HOST=postgres
|
||||
- DB_NAME=database
|
||||
- DB_USERNAME=username
|
||||
- DB_USER=username
|
||||
- DB_PASSWORD=password
|
||||
# mysql-bkup container must be connected to the same network with your database
|
||||
# Ensure the mysql-bkup container is connected to the same network as your database
|
||||
networks:
|
||||
- web
|
||||
|
||||
networks:
|
||||
web:
|
||||
```
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Notes
|
||||
|
||||
- **Supported File Formats**: The restore process supports `.sql`, `.sql.gz`, `.sql.gpg`, and `.sql.gz.gpg` files.
|
||||
- **Encrypted Backups**: If the backup is encrypted with GPG, ensure the `GPG_PASSPHRASE` environment variable is set for automatic decryption.
|
||||
- **Network Configuration**: Ensure the `mysql-bkup` container is connected to the same network as your database.
|
||||
+65
-119
@@ -6,146 +6,92 @@ nav_order: 1
|
||||
|
||||
# About mysql-bkup
|
||||
{:.no_toc}
|
||||
MySQL Backup is a Docker container image that can be used to backup, restore and migrate MySQL database. It supports local storage, AWS S3 or any S3 Alternatives for Object Storage, and SSH remote storage.
|
||||
It also supports __encrypting__ your backups using GPG.
|
||||
|
||||
We are open to receiving stars, PRs, and issues!
|
||||
**MYSQL-BKUP** is a Docker container image designed to **backup, restore, and migrate MySQL databases**.
|
||||
It supports a variety of storage options and ensures data security through GPG encryption.
|
||||
|
||||
**MYSQL-BKUP** is designed for seamless deployment on **Docker** and **Kubernetes**, simplifying MySQL backup, restoration, and migration across environments.
|
||||
It is a lightweight, multi-architecture solution compatible with **Docker**, **Docker Swarm**, **Kubernetes**, and other container orchestration platforms.
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
### Storage Options
|
||||
- **Local storage**
|
||||
- **AWS S3** or any S3-compatible object storage
|
||||
- **FTP**
|
||||
- **SFTP**
|
||||
- **SSH-compatible storage**
|
||||
- **Azure Blob storage**
|
||||
|
||||
### Data Security
|
||||
- Backups can be encrypted using **GPG** to ensure data confidentiality.
|
||||
|
||||
### Deployment Flexibility
|
||||
- Available as the [jkaninda/pg-bkup](https://hub.docker.com/r/jkaninda/mysql-bkup) Docker image.
|
||||
- Deployable on **Docker**, **Docker Swarm**, and **Kubernetes**.
|
||||
- Supports recurring backups of MySQL databases:
|
||||
- On Docker for automated backup schedules.
|
||||
- As a **Job** or **CronJob** on Kubernetes.
|
||||
|
||||
### Notifications
|
||||
- Receive real-time updates on backup success or failure via:
|
||||
- **Telegram**
|
||||
- **Email**
|
||||
|
||||
---
|
||||
|
||||
## 💡Use Cases
|
||||
|
||||
- **Scheduled Backups**: Automate recurring backups using Docker or Kubernetes.
|
||||
- **Disaster Recovery:** Quickly restore backups to a clean MySQL instance.
|
||||
- **Database Migration**: Seamlessly move data across environments using the built-in `migrate` feature.
|
||||
- **Secure Archiving:** Keep backups encrypted and safely stored in the cloud or remote servers.
|
||||
|
||||
|
||||
## ✅ Verified Platforms:
|
||||
MYSQL-BKUP has been tested and runs successfully on:
|
||||
|
||||
- Docker
|
||||
- Docker Swarm
|
||||
- Kubernetes
|
||||
- OpenShift
|
||||
|
||||
---
|
||||
|
||||
## Get Involved
|
||||
|
||||
We welcome contributions! Feel free to give us a ⭐, submit PRs, or open issues on our [GitHub repository](https://github.com/jkaninda/mysql-bkup).
|
||||
|
||||
{: .fs-6 .fw-300 }
|
||||
|
||||
---
|
||||
|
||||
The [jkaninda/mysql-bkup](https://hub.docker.com/r/jkaninda/mysql-bkup) Docker image can be deployed on Docker, Docker Swarm and Kubernetes.
|
||||
It handles __recurring__ backups of postgres database on Docker and can be deployed as __CronJob on Kubernetes__ using local, AWS S3 or SSH compatible storage.
|
||||
|
||||
It also supports database __encryption__ using GPG.
|
||||
|
||||
|
||||
{: .note }
|
||||
Code and documentation for `v1` version on [this branch][v1-branch].
|
||||
Code and documentation for the `v1` version are available on [this branch][v1-branch].
|
||||
|
||||
[v1-branch]: https://github.com/jkaninda/mysql-bkup
|
||||
|
||||
---
|
||||
|
||||
## Quickstart
|
||||
## Available Image Registries
|
||||
|
||||
### Simple backup using Docker CLI
|
||||
The Docker image is published to both **Docker Hub** and the **GitHub Container Registry**. You can use either of the following:
|
||||
|
||||
To run a one time backup, bind your local volume to `/backup` in the container and run the `backup` command:
|
||||
|
||||
```shell
|
||||
docker run --rm --network your_network_name \
|
||||
-v $PWD/backup:/backup/ \
|
||||
-e "DB_HOST=dbhost" \
|
||||
-e "DB_USERNAME=username" \
|
||||
-e "DB_PASSWORD=password" \
|
||||
jkaninda/mysql-bkup backup -d database_name
|
||||
```
|
||||
|
||||
Alternatively, pass a `--env-file` in order to use a full config as described below.
|
||||
|
||||
```yaml
|
||||
docker run --rm --network your_network_name \
|
||||
--env-file your-env-file \
|
||||
-v $PWD/backup:/backup/ \
|
||||
jkaninda/mysql-bkup backup -d database_name
|
||||
```
|
||||
|
||||
### Simple backup in docker compose file
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mysql-bkup:
|
||||
# In production, it is advised to lock your image tag to a proper
|
||||
# release version instead of using `latest`.
|
||||
# Check https://github.com/jkaninda/mysql-bkup/releases
|
||||
# for a list of available releases.
|
||||
image: jkaninda/mysql-bkup
|
||||
container_name: mysql-bkup
|
||||
command: backup
|
||||
volumes:
|
||||
- ./backup:/backup
|
||||
environment:
|
||||
- DB_PORT=3306
|
||||
- DB_HOST=mysql
|
||||
- DB_NAME=foo
|
||||
- DB_USERNAME=bar
|
||||
- DB_PASSWORD=password
|
||||
# mysql-bkup container must be connected to the same network with your database
|
||||
networks:
|
||||
- web
|
||||
networks:
|
||||
web:
|
||||
```
|
||||
## Kubernetes
|
||||
|
||||
```yaml
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: backup-job
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 100
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: mysql-bkup
|
||||
# In production, it is advised to lock your image tag to a proper
|
||||
# release version instead of using `latest`.
|
||||
# Check https://github.com/jkaninda/mysql-bkup/releases
|
||||
# for a list of available releases.
|
||||
image: jkaninda/mysql-bkup
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- backup -d dbname
|
||||
resources:
|
||||
limits:
|
||||
memory: "128Mi"
|
||||
cpu: "500m"
|
||||
env:
|
||||
- name: DB_HOST
|
||||
value: "mysql"
|
||||
- name: DB_USERNAME
|
||||
value: "user"
|
||||
- name: DB_PASSWORD
|
||||
value: "password"
|
||||
volumeMounts:
|
||||
- mountPath: /backup
|
||||
name: backup
|
||||
volumes:
|
||||
- name: backup
|
||||
hostPath:
|
||||
path: /home/toto/backup # directory location on host
|
||||
type: Directory # this field is optional
|
||||
restartPolicy: Never
|
||||
```
|
||||
|
||||
## Available image registries
|
||||
|
||||
This Docker image is published to both Docker Hub and the GitHub container registry.
|
||||
Depending on your preferences and needs, you can reference both `jkaninda/mysql-bkup` as well as `ghcr.io/jkaninda/mysql-bkup`:
|
||||
|
||||
```
|
||||
```bash
|
||||
docker pull jkaninda/mysql-bkup
|
||||
docker pull ghcr.io/jkaninda/mysql-bkup
|
||||
```
|
||||
|
||||
Documentation references Docker Hub, but all examples will work using ghcr.io just as well.
|
||||
While the documentation references Docker Hub, all examples work seamlessly with `ghcr.io`.
|
||||
|
||||
## Supported Engines
|
||||
|
||||
This image is developed and tested against the Docker CE engine and Kubernetes exclusively.
|
||||
While it may work against different implementations, there are no guarantees about support for non-Docker engines.
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
We decided to publish this image as a simpler and more lightweight alternative because of the following requirements:
|
||||
We created this image as a simpler and more lightweight alternative to existing solutions. Here’s why:
|
||||
|
||||
- The original image is based on `ubuntu` and requires additional tools, making it heavy.
|
||||
- This image is written in Go.
|
||||
- `arm64` and `arm/v7` architectures are supported.
|
||||
- Docker in Swarm mode is supported.
|
||||
- Kubernetes is supported.
|
||||
- **Lightweight:** Written in Go, the image is optimized for performance and minimal resource usage.
|
||||
- **Multi-Architecture Support:** Supports `arm64` and `arm/v7` architectures.
|
||||
- **Docker Swarm Support:** Fully compatible with Docker in Swarm mode.
|
||||
- **Kubernetes Support:** Designed to work seamlessly with Kubernetes.
|
||||
@@ -0,0 +1,212 @@
|
||||
---
|
||||
title: Quickstart
|
||||
layout: home
|
||||
nav_order: 2
|
||||
---
|
||||
|
||||
# Quickstart
|
||||
|
||||
This guide provides quick examples for running backups using Docker CLI, Docker Compose, and Kubernetes.
|
||||
|
||||
---
|
||||
|
||||
### Simple Backup Using Docker CLI
|
||||
|
||||
To perform a one-time backup, bind your local volume to `/backup` in the container and run the `backup` command:
|
||||
|
||||
```shell
|
||||
docker run --rm --network your_network_name \
|
||||
-v $PWD/backup:/backup/ \
|
||||
-e "DB_HOST=dbhost" \
|
||||
-e "DB_PORT=3306" \
|
||||
-e "DB_USER=username" \
|
||||
-e "DB_PASSWORD=password" \
|
||||
jkaninda/mysql-bkup backup -d database_name
|
||||
```
|
||||
|
||||
Alternatively, use an environment file (`--env-file`) for configuration:
|
||||
|
||||
```shell
|
||||
docker run --rm --network your_network_name \
|
||||
--env-file your-env-file \
|
||||
-v $PWD/backup:/backup/ \
|
||||
jkaninda/mysql-bkup backup -d database_name
|
||||
```
|
||||
|
||||
### Backup All Databases
|
||||
|
||||
To back up all databases on the server, use the `--all-databases` or `-a` flag. By default, this creates individual backup files for each database.
|
||||
|
||||
```shell
|
||||
docker run --rm --network your_network_name \
|
||||
-v $PWD/backup:/backup/ \
|
||||
-e "DB_HOST=dbhost" \
|
||||
-e "DB_PORT=3306" \
|
||||
-e "DB_USER=username" \
|
||||
-e "DB_PASSWORD=password" \
|
||||
jkaninda/mysql-bkup backup --all-databases --disable-compression
|
||||
```
|
||||
|
||||
> **Note:** Use the `--all-in-one` or `-A` flag to combine backups into a single file.
|
||||
|
||||
---
|
||||
|
||||
### Simple Restore Using Docker CLI
|
||||
|
||||
To restore a database, bind your local volume to `/backup` and run the `restore` command:
|
||||
|
||||
```shell
|
||||
docker run --rm --network your_network_name \
|
||||
-v $PWD/backup:/backup/ \
|
||||
-e "DB_HOST=dbhost" \
|
||||
-e "DB_PORT=3306" \
|
||||
-e "DB_USER=username" \
|
||||
-e "DB_PASSWORD=password" \
|
||||
jkaninda/mysql-bkup restore -d database_name -f backup_file.sql.gz
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Backup with Docker Compose
|
||||
|
||||
Below is an example of a `docker-compose.yml` file for running a one-time backup:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mysql-bkup:
|
||||
# In production, pin your image tag to a specific release version instead of `latest`.
|
||||
# See available releases: https://github.com/jkaninda/mysql-bkup/releases
|
||||
image: jkaninda/mysql-bkup
|
||||
container_name: mysql-bkup
|
||||
command: backup
|
||||
volumes:
|
||||
- ./backup:/backup
|
||||
environment:
|
||||
- DB_PORT=3306
|
||||
- DB_HOST=mysql
|
||||
- DB_NAME=foo
|
||||
- DB_USER=bar
|
||||
- DB_PASSWORD=password
|
||||
- TZ=Europe/Paris
|
||||
networks:
|
||||
- web
|
||||
|
||||
networks:
|
||||
web:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Recurring Backups with Docker
|
||||
|
||||
You can schedule recurring backups using the `--cron-expression` or `-e` flag:
|
||||
|
||||
```shell
|
||||
docker run --rm --network network_name \
|
||||
-v $PWD/backup:/backup/ \
|
||||
-e "DB_HOST=hostname" \
|
||||
-e "DB_USER=user" \
|
||||
-e "DB_PASSWORD=password" \
|
||||
jkaninda/mysql-bkup backup -d dbName --cron-expression "@every 15m"
|
||||
```
|
||||
|
||||
For predefined schedules, refer to the [documentation](https://jkaninda.github.io/mysql-bkup/reference/#predefined-schedules).
|
||||
|
||||
---
|
||||
|
||||
## Deploy on Kubernetes
|
||||
|
||||
For Kubernetes, you can deploy `mysql-bkup` as a Job or CronJob. Below are examples for both.
|
||||
|
||||
### Kubernetes Backup Job
|
||||
|
||||
This example defines a one-time backup job:
|
||||
|
||||
```yaml
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: backup-job
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 100
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: mysql-bkup
|
||||
# Pin the image tag to a specific release version in production.
|
||||
# See available releases: https://github.com/jkaninda/mysql-bkup/releases
|
||||
image: jkaninda/mysql-bkup
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- backup -d dbname
|
||||
resources:
|
||||
limits:
|
||||
memory: "128Mi"
|
||||
cpu: "500m"
|
||||
env:
|
||||
- name: DB_HOST
|
||||
value: "mysql"
|
||||
- name: DB_USER
|
||||
value: "user"
|
||||
- name: DB_PASSWORD
|
||||
value: "password"
|
||||
volumeMounts:
|
||||
- mountPath: /backup
|
||||
name: backup
|
||||
volumes:
|
||||
- name: backup
|
||||
hostPath:
|
||||
path: /home/toto/backup # Directory location on the host
|
||||
type: Directory # Optional field
|
||||
restartPolicy: Never
|
||||
```
|
||||
|
||||
### Kubernetes CronJob for Scheduled Backups
|
||||
|
||||
For scheduled backups, use a `CronJob`:
|
||||
|
||||
```yaml
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: pg-bkup-cronjob
|
||||
spec:
|
||||
schedule: "0 2 * * *" # Runs daily at 2 AM
|
||||
jobTemplate:
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: pg-bkup
|
||||
image: jkaninda/mysql-bkup
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- backup -d dbname
|
||||
env:
|
||||
- name: DB_HOST
|
||||
value: "mysql"
|
||||
- name: DB_USER
|
||||
value: "user"
|
||||
- name: DB_PASSWORD
|
||||
value: "password"
|
||||
volumeMounts:
|
||||
- mountPath: /backup
|
||||
name: backup
|
||||
volumes:
|
||||
- name: backup
|
||||
hostPath:
|
||||
path: /home/toto/backup
|
||||
type: Directory
|
||||
restartPolicy: OnFailure
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Notes
|
||||
|
||||
- **Volume Binding**: Ensure the `/backup` directory is mounted to persist backup files.
|
||||
- **Environment Variables**: Use environment variables or an `--env-file` to pass database credentials and other configurations.
|
||||
- **Cron Expressions**: Use standard cron expressions or predefined schedules for recurring backups.
|
||||
- **Kubernetes Jobs**: Use Kubernetes `Job` or `CronJob` for running backups in a Kubernetes cluster.
|
||||
+275
-86
@@ -1,113 +1,302 @@
|
||||
---
|
||||
title: Configuration Reference
|
||||
layout: default
|
||||
nav_order: 2
|
||||
nav_order: 3
|
||||
---
|
||||
|
||||
# Configuration reference
|
||||
# Configuration Reference
|
||||
|
||||
Backup, restore and migrate targets, schedule and retention are configured using environment variables or flags.
|
||||
mysql-bkup is configured with **environment variables**, **CLI flags** and an optional **YAML file**. For every setting the order is: flag, environment variable, deprecated name, YAML `env:` section, default. Every secret (`DB_PASSWORD`, `AWS_SECRET_KEY`, `GPG_PASSPHRASE`, ...) can also be read from a file with `<NAME>_FILE`.
|
||||
|
||||
Run `mysql-bkup config` to see the resolved configuration and where each value comes from (secrets redacted).
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `backup` | Back up now, or as a daemon with `--cron-expression` |
|
||||
| `restore` | Restore a backup: `-f FILE [-d DB]` |
|
||||
| `migrate` | Copy a database (or every database with `-a`) into `TARGET_DB_*`, streamed |
|
||||
| `verify` | Download, decrypt and decompress a backup and check its sha256 |
|
||||
| `list` | Backup history from the index (`--json`, `--storage-scan`) |
|
||||
| `delete` | Delete a backup, its sidecar and its index entry (`-f FILE`) |
|
||||
| `prune` | Apply the retention policy (`--dry-run`) |
|
||||
| `reconcile` | Rebuild the backup index (`--from-index OLD`, `--dry-run`) |
|
||||
| `config` | Show the resolved configuration; `--env-docs` prints the tables below |
|
||||
| `health` | Exit 0 when the daemon is healthy (Docker `HEALTHCHECK`) |
|
||||
| `version` | Print the version |
|
||||
|
||||
## Flags
|
||||
|
||||
### CLI utility Usage
|
||||
| Flag | Short | Commands | Variable |
|
||||
|---|---|---|---|
|
||||
| `--dbname` | `-d` | all | `DB_NAME` |
|
||||
| `--storage` | `-s` | all | `STORAGE` (`local`, `s3`, `gcs`, `azure`, `sftp`/`ssh`/`remote`, `ftp`) |
|
||||
| `--path` (`--remote-path`) | `-P` | all | `REMOTE_PATH` |
|
||||
| `--file` | `-f` | restore, verify, delete | `FILE_NAME` |
|
||||
| `--config` | `-c` | backup, list, prune, config | `BACKUP_CONFIG_FILE` |
|
||||
| `--cron-expression` | `-e` | backup | `BACKUP_CRON_EXPRESSION` |
|
||||
| `--all-databases` | `-a` | backup, migrate | `BACKUP_ALL_DATABASES` |
|
||||
| `--all-in-one` | `-A` | backup | `BACKUP_ALL_IN_ONE` |
|
||||
| `--exclude-db` | | backup | `BACKUP_EXCLUDE_DATABASES` |
|
||||
| `--custom-name` | | backup | `BACKUP_CUSTOM_NAME` (fixed name, no timestamp) |
|
||||
| `--name` | `-n` | backup, list, prune | `BACKUP_NAME` |
|
||||
| `--disable-compression` | | backup | `DISABLE_COMPRESSION` |
|
||||
| `--compression` | | backup | `COMPRESSION` (`gzip`, `zstd`, `none`) |
|
||||
| `--encryption` | | backup | `ENCRYPTION` (`auto`, `none`, `gpg`, `age`) |
|
||||
| `--index-name` | | all | `BACKUP_INDEX_NAME` |
|
||||
| `--result-file` | | all | `RESULT_FILE` |
|
||||
| `--help` | `-h` | all | |
|
||||
| `--version` | `-v` | root | |
|
||||
|
||||
| Options | Shorts | Usage |
|
||||
|-----------------------|--------|----------------------------------------------------------------------------------------|
|
||||
| mysql-bkup | bkup | CLI utility |
|
||||
| backup | | Backup database operation |
|
||||
| restore | | Restore database operation |
|
||||
| migrate | | Migrate database from one instance to another one |
|
||||
| --storage | -s | Storage. local or s3 (default: local) |
|
||||
| --file | -f | File name for restoration |
|
||||
| --path | | AWS S3 path without file name. eg: /custom_path or ssh remote path `/home/foo/backup` |
|
||||
| --dbname | -d | Database name |
|
||||
| --port | -p | Database port (default: 3306) |
|
||||
| --mode | -m | Execution mode. default or scheduled (default: default) |
|
||||
| --disable-compression | | Disable database backup compression |
|
||||
| --prune | | Delete old backup, default disabled |
|
||||
| --keep-last | | Delete old backup created more than specified days ago, default 7 days |
|
||||
| --period | | Crontab period for scheduled mode only. (default: "0 1 * * *") |
|
||||
| --help | -h | Print this help message and exit |
|
||||
| --version | -V | Print version information and exit |
|
||||
## Exit codes
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| `0` | success |
|
||||
| `1` | failure |
|
||||
| `2` | configuration error |
|
||||
| `3` | database connection or authentication |
|
||||
| `4` | storage |
|
||||
| `5` | encryption or decryption |
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Name | Requirement | Description |
|
||||
|------------------------|----------------------------------------------------|------------------------------------------------------|
|
||||
| DB_PORT | Optional, default 3306 | Database port number |
|
||||
| DB_HOST | Required | Database host |
|
||||
| DB_NAME | Optional if it was provided from the -d flag | Database name |
|
||||
| DB_USERNAME | Required | Database user name |
|
||||
| DB_PASSWORD | Required | Database password |
|
||||
| AWS_ACCESS_KEY | Optional, required for S3 storage | AWS S3 Access Key |
|
||||
| AWS_SECRET_KEY | Optional, required for S3 storage | AWS S3 Secret Key |
|
||||
| AWS_BUCKET_NAME | Optional, required for S3 storage | AWS S3 Bucket Name |
|
||||
| AWS_BUCKET_NAME | Optional, required for S3 storage | AWS S3 Bucket Name |
|
||||
| AWS_REGION | Optional, required for S3 storage | AWS Region |
|
||||
| AWS_DISABLE_SSL | Optional, required for S3 storage | Disable SSL |
|
||||
| FILE_NAME | Optional if it was provided from the --file flag | Database file to restore (extensions: .sql, .sql.gz) |
|
||||
| BACKUP_CRON_EXPRESSION | Optional if it was provided from the --period flag | Backup cron expression for docker in scheduled mode |
|
||||
| GPG_PASSPHRASE | Optional, required to encrypt and restore backup | GPG passphrase |
|
||||
| SSH_HOST_NAME | Optional, required for SSH storage | ssh remote hostname or ip |
|
||||
| SSH_USER | Optional, required for SSH storage | ssh remote user |
|
||||
| SSH_PASSWORD | Optional, required for SSH storage | ssh remote user's password |
|
||||
| SSH_IDENTIFY_FILE | Optional, required for SSH storage | ssh remote user's private key |
|
||||
| SSH_PORT | Optional, required for SSH storage | ssh remote server port |
|
||||
| SSH_REMOTE_PATH | Optional, required for SSH storage | ssh remote path (/home/toto/backup) |
|
||||
| TARGET_DB_HOST | Optional, required for database migration | Target database host |
|
||||
| TARGET_DB_PORT | Optional, required for database migration | Target database port |
|
||||
| TARGET_DB_NAME | Optional, required for database migration | Target database name |
|
||||
| TARGET_DB_USERNAME | Optional, required for database migration | Target database username |
|
||||
| TARGET_DB_PASSWORD | Optional, required for database migration | Target database password |
|
||||
| TG_TOKEN | Optional, required for Telegram notification | Telegram token |
|
||||
| TG_CHAT_ID | Optional, required for Telegram notification | Telegram Chat ID |
|
||||
---
|
||||
## Run in Scheduled mode
|
||||
### storage
|
||||
|
||||
This image can be run as CronJob in Kubernetes for a regular backup which makes deployment on Kubernetes easy as Kubernetes has CronJob resources.
|
||||
For Docker, you need to run it in scheduled mode by adding `--mode scheduled` flag and specify the periodical backup time by adding `--period "0 1 * * *"` flag.
|
||||
| Variable | Flag | Default | Fallbacks | Description |
|
||||
|---|---|---|---|---|
|
||||
| `STORAGE` | `--storage` `-s` | `local` | | Storage backend: local, s3, gcs, azure, sftp (ssh, remote), ftp (local, s3, gcs, azure, sftp, ssh, ftp, remote) |
|
||||
| `STORAGE_PATH` | | `/backup` | | Root directory of the local storage backend |
|
||||
| `REMOTE_PATH` | `--path` `-P` | | `AWS_S3_PATH` (deprecated), `S3_PATH` (deprecated) | Folder inside the storage backend (without file name) |
|
||||
|
||||
## Syntax of crontab (field description)
|
||||
### general
|
||||
|
||||
The syntax is:
|
||||
| Variable | Flag | Default | Fallbacks | Description |
|
||||
|---|---|---|---|---|
|
||||
| `BACKUP_NAME` | `--name` `-n` | | | Base name of the artifact; a timestamp is appended (default: the database or volume name) |
|
||||
| `BACKUP_CUSTOM_NAME` | `--custom-name` | | | Fixed artifact name without timestamp (overwrites the previous one) |
|
||||
| `FILE_NAME` | `--file` `-f` | | | Backup file to restore, verify or delete |
|
||||
| `BACKUP_CONFIG_FILE` | `--config` `-c` | | | YAML configuration file (default /config/config.yaml if present) |
|
||||
| `BACKUP_TIMEOUT` | | | | Abort a backup after this duration (0 = none) |
|
||||
| `RESTORE_TIMEOUT` | | | | Abort a restore after this duration (0 = none) |
|
||||
| `TMP_DIR` | | `/tmp/backup` | `BACKUP_TMP_DIR` (deprecated) | Scratch directory (only for secret files and disk-based modes) |
|
||||
| `COMPRESSION` | `--compression` | `gzip` | | Compression: gzip, zstd or none (gzip, zstd, none) |
|
||||
| `COMPRESSION_LEVEL` | | | | Compression level (0 = default) |
|
||||
| `DISABLE_COMPRESSION` | `--disable-compression` | | | Same as COMPRESSION=none |
|
||||
| `RESTORE_VERIFY_FIRST` | `--verify-first` | `auto` | | Verify the whole artifact before restoring (auto: on for GPG-encrypted volumes) (auto, true, false) |
|
||||
| `LOG_LEVEL` | | `info` | | Log level (debug, info, warn, error) |
|
||||
| `LOG_FORMAT` | | `text` | | Log format (text, json) |
|
||||
| `TIME_FORMAT` | | `2006-01-02 15:04:05` | | Time layout used in notifications |
|
||||
| `RESULT_FILE` | `--result-file` | | | Write the run result as JSON to this path |
|
||||
|
||||
- 1: Minute (0-59)
|
||||
- 2: Hours (0-23)
|
||||
- 3: Day (0-31)
|
||||
- 4: Month (0-12 [12 == December])
|
||||
- 5: Day of the week(0-7 [7 or 0 == sunday])
|
||||
### scheduling
|
||||
|
||||
Easy to remember format:
|
||||
| Variable | Flag | Default | Fallbacks | Description |
|
||||
|---|---|---|---|---|
|
||||
| `BACKUP_CRON_EXPRESSION` | `--cron-expression` `-e` | | | Run as a daemon on this cron schedule (5 or 6 fields, @daily, @every 6h) |
|
||||
| `BACKUP_RUN_ON_START` | | | | In daemon mode, run one backup immediately at start |
|
||||
| `BACKUP_RESCUE_MODE` | | | | Deprecated: runs always continue with the remaining targets after a failure |
|
||||
| `METRICS_ADDR` | | | | Serve Prometheus /metrics and /healthz on this address in daemon mode (e.g. :9090) |
|
||||
| `HEALTH_FILE` | | `/tmp/bkup-health.json` | | State file read by the health command |
|
||||
| `HEALTH_MAX_AGE` | | | | health fails when the last success is older than this (default: 2x the schedule interval) |
|
||||
|
||||
### retention
|
||||
|
||||
| Variable | Flag | Default | Fallbacks | Description |
|
||||
|---|---|---|---|---|
|
||||
| `BACKUP_RETENTION_DAYS` | | `0` | | Delete backups older than N days (0 = keep) |
|
||||
| `BACKUP_RETENTION_COUNT` | | `0` | | Keep only the newest N backups (0 = no limit) |
|
||||
| `BACKUP_RETENTION_ALLOW_ROOT` | | | | Allow pattern-based retention at the storage root (index disabled) |
|
||||
| `BACKUP_RETENTION_ADOPT_LEGACY` | | | | Import pre-index backups matching this tool's naming into the index |
|
||||
| `BACKUP_INDEX` | | `true` | | Keep the backup index file and per-backup .meta.json sidecars |
|
||||
| `BACKUP_INDEX_NAME` | `--index-name` | `{tool}-metadata.json` | | Index file name; placeholders {tool} {engine} {name} {db} |
|
||||
|
||||
### storage-s3
|
||||
|
||||
| Variable | Flag | Default | Fallbacks | Description |
|
||||
|---|---|---|---|---|
|
||||
| `AWS_S3_ENDPOINT` | | | `S3_ENDPOINT` (deprecated), `AWS_ENDPOINT_URL_S3`, `AWS_ENDPOINT_URL` | S3 endpoint (empty = AWS) |
|
||||
| `AWS_S3_BUCKET_NAME` | | | `BUCKET_NAME` (deprecated), `AWS_BUCKET_NAME` (deprecated) | S3 bucket |
|
||||
| `AWS_REGION` | | `us-east-1` | `AWS_DEFAULT_REGION` | S3 region |
|
||||
| `AWS_ACCESS_KEY` | | | `ACCESS_KEY` (deprecated), `AWS_ACCESS_KEY_ID` | S3 access key (empty = default AWS credential chain) |
|
||||
| `AWS_SECRET_KEY` | | | `SECRET_KEY` (deprecated), `AWS_SECRET_ACCESS_KEY`, `AWS_SECRET_KEY_FILE` | S3 secret key |
|
||||
| `AWS_SESSION_TOKEN` | | | `AWS_SESSION_TOKEN_FILE` | S3 session token |
|
||||
| `AWS_PROFILE` | | | | AWS shared config profile |
|
||||
| `AWS_DISABLE_SSL` | | | | Use http for the S3 endpoint |
|
||||
| `AWS_FORCE_PATH_STYLE` | | | | Path-style S3 addressing (MinIO) |
|
||||
| `S3_STORAGE_CLASS` | | | | S3 storage class |
|
||||
| `S3_SSE` | | | | Server-side encryption: AES256 or aws:kms (aes256, aws:kms) |
|
||||
| `S3_SSE_KMS_KEY_ID` | | | | KMS key id for aws:kms |
|
||||
| `S3_PART_SIZE` | | `64MiB` | | Multipart part size; max object size is 10000 x this |
|
||||
| `S3_UPLOAD_CONCURRENCY` | | `2` | | Parallel part uploads (memory = part size x this) |
|
||||
|
||||
### storage-sftp
|
||||
|
||||
| Variable | Flag | Default | Fallbacks | Description |
|
||||
|---|---|---|---|---|
|
||||
| `SFTP_HOST` | | | | SFTP host |
|
||||
| `SFTP_PORT` | | `22` | | SFTP port |
|
||||
| `SFTP_USER` | | | | SFTP user |
|
||||
| `SFTP_PASSWORD` | | | `SFTP_PASSWORD_FILE` | SFTP password |
|
||||
| `SFTP_IDENTITY_FILE` | | | | SFTP private key file (the SSH agent is also used when SSH_AUTH_SOCK is set) |
|
||||
| `SFTP_IDENTITY_PASSPHRASE` | | | `SFTP_IDENTITY_PASSPHRASE_FILE` | Passphrase of the SFTP private key |
|
||||
| `SFTP_KNOWN_HOSTS` | | | | known_hosts file used to verify the SFTP host key (default ~/.ssh/known_hosts) |
|
||||
| `SFTP_INSECURE_IGNORE_HOST_KEY` | | | | Do not verify the SFTP host key (insecure) |
|
||||
|
||||
### storage-ftp
|
||||
|
||||
| Variable | Flag | Default | Fallbacks | Description |
|
||||
|---|---|---|---|---|
|
||||
| `FTP_HOST` | | | `FTP_HOST_NAME` (deprecated) | FTP host |
|
||||
| `FTP_PORT` | | `21` | | FTP port |
|
||||
| `FTP_USER` | | | | FTP user |
|
||||
| `FTP_PASSWORD` | | | `FTP_PASSWORD_FILE` | FTP password |
|
||||
| `FTP_TLS` | | | | Use explicit FTPS |
|
||||
|
||||
### storage-azure
|
||||
|
||||
| Variable | Flag | Default | Fallbacks | Description |
|
||||
|---|---|---|---|---|
|
||||
| `AZURE_STORAGE_ACCOUNT_NAME` | | | | Azure storage account |
|
||||
| `AZURE_STORAGE_ACCOUNT_KEY` | | | `AZURE_STORAGE_ACCOUNT_KEY_FILE` | Azure storage account key |
|
||||
| `AZURE_STORAGE_CONTAINER_NAME` | | | | Azure blob container |
|
||||
| `AZURE_STORAGE_ENDPOINT` | | | | Azure blob endpoint (Azurite, sovereign clouds) |
|
||||
| `AZURE_STORAGE_SAS_TOKEN` | | | `AZURE_STORAGE_SAS_TOKEN_FILE` | Azure SAS token (instead of the account key) |
|
||||
|
||||
### storage-gcs
|
||||
|
||||
| Variable | Flag | Default | Fallbacks | Description |
|
||||
|---|---|---|---|---|
|
||||
| `GCS_BUCKET` | | | | GCS bucket |
|
||||
| `GCS_CREDENTIALS_FILE` | | | `GOOGLE_APPLICATION_CREDENTIALS` | GCS service account JSON file |
|
||||
| `GCS_CREDENTIALS_JSON` | | | `GCS_CREDENTIALS_JSON_FILE` | GCS service account JSON content |
|
||||
| `GCS_ENDPOINT` | | | | GCS endpoint (emulators) |
|
||||
|
||||
### encryption
|
||||
|
||||
| Variable | Flag | Default | Fallbacks | Description |
|
||||
|---|---|---|---|---|
|
||||
| `ENCRYPTION` | `--encryption` | `auto` | | auto (from the keys set), none, gpg or age (auto, none, gpg, age) |
|
||||
| `ENCRYPTION_ARMOR` | | | | ASCII-armor encrypted output |
|
||||
| `GPG_PASSPHRASE` | | | `GPG_PASSPHRASE_FILE` | GPG symmetric passphrase |
|
||||
| `GPG_PUBLIC_KEY` | | | | GPG public key(s): file path or inline key (default /config/gnupg/public_key.{asc,gpg}) |
|
||||
| `GPG_PRIVATE_KEY` | | | `GPG_PRIVATE_KEY_FILE` | GPG private key for restore: file path or inline key (default /config/gnupg/private_key.{asc,gpg}) |
|
||||
| `GPG_PRIVATE_KEY_PASSPHRASE` | | | `GPG_PRIVATE_KEY_PASSPHRASE_FILE` | Passphrase of the GPG private key (default GPG_PASSPHRASE) |
|
||||
| `AGE_RECIPIENTS` | | | | age recipients (age1..., ssh-ed25519 ...), comma or newline separated |
|
||||
| `AGE_RECIPIENTS_FILE` | | | | File with age recipients |
|
||||
| `AGE_PASSPHRASE` | | | `AGE_PASSPHRASE_FILE` | age passphrase (scrypt) |
|
||||
| `AGE_IDENTITY` | | | `AGE_IDENTITY_FILE` | age identity (AGE-SECRET-KEY-...) for restore |
|
||||
| `AGE_IDENTITY_FILE` | | | | age identity file for restore (default /config/age/identity.txt) |
|
||||
|
||||
### notifications
|
||||
|
||||
| Variable | Flag | Default | Fallbacks | Description |
|
||||
|---|---|---|---|---|
|
||||
| `MAIL_HOST` | | | | SMTP host |
|
||||
| `MAIL_PORT` | | `587` | | SMTP port |
|
||||
| `MAIL_USERNAME` | | | | SMTP user |
|
||||
| `MAIL_PASSWORD` | | | `MAIL_PASSWORD_FILE` | SMTP password |
|
||||
| `MAIL_FROM` | | | | Sender address |
|
||||
| `MAIL_TO` | | | | Recipients, comma separated |
|
||||
| `MAIL_SKIP_TLS` | | | | Deprecated: true skips certificate verification (use MAIL_INSECURE_SKIP_VERIFY) |
|
||||
| `MAIL_INSECURE_SKIP_VERIFY` | | | | Skip SMTP certificate verification |
|
||||
| `MAIL_TLS` | | `starttls` | | SMTP TLS mode (starttls, tls, none) |
|
||||
| `TG_TOKEN` | | | `TELEGRAM_BOT_TOKEN`, `TG_TOKEN_FILE` | Telegram bot token |
|
||||
| `TG_CHAT_ID` | | | `TELEGRAM_CHAT_ID` | Telegram chat id |
|
||||
| `POSTA_URL` | | | | Posta base URL (https://posta.example.com) for email notifications through Posta |
|
||||
| `POSTA_API_KEY` | | | `POSTA_API_KEY_FILE` | Posta API key with the send scope |
|
||||
| `POSTA_WORKSPACE_ID` | | | | Posta workspace id (only for account-wide API keys) |
|
||||
| `POSTA_FROM` | | | | Posta sender address (default MAIL_FROM) |
|
||||
| `POSTA_TO` | | | | Posta recipients, comma separated (default MAIL_TO) |
|
||||
| `POSTA_TEMPLATE` | | | | Posta template name for notifications; the event fields are passed as template data (default: built-in HTML) |
|
||||
| `POSTA_TEMPLATE_ERROR` | | | | Posta template name for failures (default POSTA_TEMPLATE) |
|
||||
| `POSTA_LANGUAGE` | | | | Posta template language |
|
||||
| `WEBHOOK_URL` | | | | Generic JSON webhook URL |
|
||||
| `WEBHOOK_SECRET` | | | `WEBHOOK_SECRET_FILE` | HMAC-SHA256 secret for the X-Bkup-Signature header |
|
||||
| `SLACK_WEBHOOK_URL` | | | `SLACK_WEBHOOK_URL_FILE` | Slack incoming webhook URL |
|
||||
| `DISCORD_WEBHOOK_URL` | | | `DISCORD_WEBHOOK_URL_FILE` | Discord webhook URL |
|
||||
| `NTFY_URL` | | | | ntfy topic URL |
|
||||
| `NTFY_TOKEN` | | | `NTFY_TOKEN_FILE` | ntfy access token |
|
||||
| `NOTIFY_ON` | | `always` | | When to notify (always, failure, success, never) |
|
||||
| `HEALTHCHECK_PING_URL` | | | | healthchecks.io-style URL pinged on start (/start), success and failure (/fail) |
|
||||
| `BACKUP_REFERENCE` | | | | Free text included in notifications |
|
||||
| `TEMPLATES_DIR` | | `/config/templates` | | Directory with custom notification templates |
|
||||
|
||||
### database
|
||||
|
||||
| Variable | Flag | Default | Fallbacks | Description |
|
||||
|---|---|---|---|---|
|
||||
| `DB_URL` | | | `DB_URL_FILE` | Connection URL mysql://user:password@host:port/database?ssl-mode=required (overrides host, port, user, password) |
|
||||
| `DB_HOST` | | `localhost` | | Database host (a path starting with / is a Unix socket) |
|
||||
| `DB_PORT` | | `3306` | | Database port |
|
||||
| `DB_NAME` | `--dbname` `-d` | | | Database name |
|
||||
| `DB_USER` | | | `DB_USERNAME` | Database user |
|
||||
| `DB_PASSWORD` | | | `DB_PASSWORD_FILE` | Database password |
|
||||
| `DB_SSL_MODE` | | `disabled` | | TLS mode: disabled, preferred, required, verify_ca, verify_identity (1.x values 0 and 1 mean disabled and required) |
|
||||
| `DB_SSL_CA` | | `/etc/ssl/certs/ca-certificates.crt` | | CA certificate file, used with DB_SSL_MODE=verify_ca or verify_identity |
|
||||
| `DB_SSL_CERT` | | | | Client certificate file |
|
||||
| `DB_SSL_KEY` | | | | Client key file |
|
||||
| `BACKUP_ALL_DATABASES` | `--all-databases` `-a` | | | Back up every database, one file each |
|
||||
| `BACKUP_ALL_IN_ONE` | `--all-in-one` `-A` | | | Back up every database into a single file |
|
||||
| `BACKUP_EXCLUDE_DATABASES` | `--exclude-db` | | | Databases to skip with --all-databases, comma separated |
|
||||
| `TARGET_DB_URL` | | | `TARGET_DB_URL_FILE` | migrate: target connection URL |
|
||||
| `TARGET_DB_HOST` | | | | migrate: target host |
|
||||
| `TARGET_DB_PORT` | | `3306` | | migrate: target port |
|
||||
| `TARGET_DB_NAME` | | | | migrate: target database (default: the source name) |
|
||||
| `TARGET_DB_USER` | | | `TARGET_DB_USERNAME` | migrate: target user |
|
||||
| `TARGET_DB_PASSWORD` | | | `TARGET_DB_PASSWORD_FILE` | migrate: target password |
|
||||
| `TARGET_DB_SSL_MODE` | | `disabled` | | migrate: target TLS mode (same values as DB_SSL_MODE) |
|
||||
| `TARGET_DB_SSL_CA` | | `/etc/ssl/certs/ca-certificates.crt` | | migrate: target CA certificate file |
|
||||
| `MYSQL_CLIENT` | | `auto` | | Client tools: auto (mariadb-dump for MariaDB, Oracle mysqldump for MySQL), mysql or mariadb (auto, mysql, mariadb) |
|
||||
| `RESTORE_NO_DEFINER` | | `auto` | | Remove DEFINER clauses on restore (views, triggers, routines and events then belong to the restoring user); auto = when that user lacks SET_USER_ID/SUPER (auto, true, false) |
|
||||
|
||||
|
||||
## Scheduled Backups
|
||||
|
||||
### Running in Scheduled Mode
|
||||
|
||||
- **Docker**: Use the `--cron-expression` flag or the `BACKUP_CRON_EXPRESSION` environment variable to schedule backups.
|
||||
- **Kubernetes**: Use a `CronJob` resource for scheduled backups.
|
||||
|
||||
### Cron Syntax
|
||||
|
||||
The cron syntax consists of five fields (a sixth, leading seconds field is also accepted):
|
||||
|
||||
```conf
|
||||
* * * * * command to be executed
|
||||
* * * * * command
|
||||
```
|
||||
|
||||
| Field | Description | Values |
|
||||
|---------------|------------------------------|----------------|
|
||||
| Minute | Minute of the hour | `0-59` |
|
||||
| Hour | Hour of the day | `0-23` |
|
||||
| Day of Month | Day of the month | `1-31` |
|
||||
| Month | Month of the year | `1-12` |
|
||||
| Day of Week | Day of the week (0 = Sunday) | `0-7` |
|
||||
|
||||
#### Examples
|
||||
|
||||
- **Every 30 minutes**: `*/30 * * * *`
|
||||
- **Every hour at minute 0**: `0 * * * *`
|
||||
- **Every day at 1:00 AM**: `0 1 * * *`
|
||||
|
||||
### Predefined Schedules
|
||||
|
||||
| Entry | Description | Equivalent To |
|
||||
|----------------------------|--------------------------------------------|---------------|
|
||||
| `@yearly` (or `@annually`) | Run once a year, midnight, Jan. 1st | `0 0 1 1 *` |
|
||||
| `@monthly` | Run once a month, midnight, first of month | `0 0 1 * *` |
|
||||
| `@weekly` | Run once a week, midnight between Sat/Sun | `0 0 * * 0` |
|
||||
| `@daily` (or `@midnight`) | Run once a day, midnight | `0 0 * * *` |
|
||||
| `@hourly` | Run once an hour, beginning of hour | `0 * * * *` |
|
||||
|
||||
### Intervals
|
||||
|
||||
You can also schedule backups at fixed intervals using the format:
|
||||
|
||||
```conf
|
||||
- - - - -
|
||||
| | | | |
|
||||
| | | | ----- Day of week (0 - 7) (Sunday=0 or 7)
|
||||
| | | ------- Month (1 - 12)
|
||||
| | --------- Day of month (1 - 31)
|
||||
| ----------- Hour (0 - 23)
|
||||
------------- Minute (0 - 59)
|
||||
@every <duration>
|
||||
```
|
||||
|
||||
> At every 30th minute
|
||||
|
||||
```conf
|
||||
*/30 * * * *
|
||||
```
|
||||
> “At minute 0.” every hour
|
||||
```conf
|
||||
0 * * * *
|
||||
```
|
||||
|
||||
> “At 01:00.” every day
|
||||
|
||||
```conf
|
||||
0 1 * * *
|
||||
```
|
||||
- Example: `@every 1h30m10s` runs the backup every 1 hour, 30 minutes, and 10 seconds.
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
title: Upgrading to v2
|
||||
layout: default
|
||||
nav_order: 5
|
||||
---
|
||||
|
||||
# Upgrading from 1.x to v2
|
||||
|
||||
v2 rebuilds mysql-bkup on a shared streaming core: dump, compression, encryption and upload run in one pipe, without temporary files, whatever the size of the database. Existing commands, flags and environment variables keep working.
|
||||
|
||||
|
||||
Everything that worked in 1.x keeps working; a few behaviours changed on purpose.
|
||||
|
||||
- **Image:** Debian 13 slim instead of Alpine, with Oracle's `mysqldump` for MySQL servers (correct MySQL 8 dumps) and `mariadb-dump` for MariaDB. `/bin/sh`, `/backup`, `/config`, the `backup`/`restore`/`migrate` shortcuts and the `bkup` alias are still there; `tini` is now PID 1.
|
||||
- **Renamed variables** are still read, with a deprecation warning: `S3_ENDPOINT` → `AWS_S3_ENDPOINT`, `BUCKET_NAME`/`AWS_BUCKET_NAME` → `AWS_S3_BUCKET_NAME`, `ACCESS_KEY`/`SECRET_KEY` → `AWS_ACCESS_KEY`/`AWS_SECRET_KEY`, `AWS_S3_PATH`/`S3_PATH` → `REMOTE_PATH`, `FTP_HOST_NAME` → `FTP_HOST`, `BACKUP_TMP_DIR` → `TMP_DIR`.
|
||||
- **`DB_USER` is the database user variable** (also `TARGET_DB_USER`). `DB_USERNAME` and `TARGET_DB_USERNAME` are still read without a warning, including the per-database forms (`DB_USERNAME_<NAME>`, `<NAME>_DB_USERNAME`).
|
||||
- **`DB_SSL_MODE`** takes MySQL's mode names; `0`/`1` still work.
|
||||
- **`STORAGE` and `STORAGE_PATH` env are honoured** (1.x always used the `--storage` default and `/backup`). Unknown storage names are an error instead of a silent local backup.
|
||||
- **SFTP variables are renamed, and the old names are no longer read:** `SSH_HOST`/`SSH_HOST_NAME` → `SFTP_HOST`, `SSH_PORT` → `SFTP_PORT`, `SSH_USER` → `SFTP_USER`, `SSH_PASSWORD` → `SFTP_PASSWORD`, `SSH_IDENTIFY_FILE`/`SSH_IDENTITY_FILE` → `SFTP_IDENTITY_FILE`, `SSH_REMOTE_PATH` → `REMOTE_PATH`.
|
||||
- **SFTP host keys are verified by default.** Set `SFTP_KNOWN_HOSTS=/path/known_hosts`, or `SFTP_INSECURE_IGNORE_HOST_KEY=true` to keep the 1.x behaviour.
|
||||
- **`MAIL_SKIP_TLS` is read with its documented meaning** (`true` skips certificate verification). 1.x had it inverted, so `MAIL_SKIP_TLS=false` (the value in `.env.example`) turned verification *off*. Use `MAIL_INSECURE_SKIP_VERIFY` from now on.
|
||||
- **Retention only deletes indexed backups** (see the [configuration reference](../reference/)). 1.x deleted every file older than N days in the folder. Set `BACKUP_RETENTION_ADOPT_LEGACY=true` once to let retention handle 1.x backups, or `BACKUP_INDEX=false` for name-based retention.
|
||||
- **Encryption fails closed**: a `GPG_PUBLIC_KEY` that cannot be read is an error (1.x uploaded plaintext). The env var now wins over the auto-detected `/config/gnupg` key.
|
||||
- **Errors no longer kill the daemon**: a failed run is logged, notified and retried at the next schedule; other databases of the same run continue (`BACKUP_RESCUE_MODE` is no longer needed).
|
||||
- **Restore** works from every storage (1.x had no SFTP restore), is case-insensitive about the storage name, and no longer goes through `sh -c`.
|
||||
- **`migrate`** streams directly into the target (no intermediate file), creates the target database if needed, and `TARGET_DB_NAME` defaults to the source name; `-a` migrates every database.
|
||||
- **Notification templates** are built in; custom ones still go in `/config/templates` (`TEMPLATES_DIR`).
|
||||
- **Version** is injected with `-ldflags "-X main.Version=..."` (was `utils.Version`).
|
||||
|
||||
|
||||
## Checklist
|
||||
|
||||
1. **SFTP users:** mount a `known_hosts` file and set `SFTP_KNOWN_HOSTS`, or set `SFTP_INSECURE_IGNORE_HOST_KEY=true`.
|
||||
2. **Email notifications with `MAIL_SKIP_TLS=false`:** certificate verification is now on, as the name always promised. Remove the variable, or set `MAIL_INSECURE_SKIP_VERIFY=true` if your SMTP server has a self-signed certificate.
|
||||
3. **Retention:** set `BACKUP_RETENTION_ADOPT_LEGACY=true` for one run if retention should also delete the backups made by 1.x.
|
||||
4. **Quoted values in Compose files:** in the list syntax (`- AWS_DISABLE_SSL="false"`) the quotes are part of the value. v2 validates booleans and rejects `"false"`; write `- AWS_DISABLE_SSL=false`.
|
||||
5. **`--user` runs:** `/backup` and `/tmp/backup` are writable by any user (sticky, like `/tmp`); mounted volumes need the usual permissions.
|
||||
6. **Restoring 1.x backups:** nothing to do. `.sql`, `.sql.gz`, `.sql.gpg` and `.sql.gz.gpg` files from 1.x restore as before.
|
||||
|
||||
## New in v2
|
||||
|
||||
- MySQL servers are dumped with Oracle `mysqldump` 8.4, MariaDB servers with `mariadb-dump` 11.8 (flavour detected automatically).
|
||||
- `DB_URL` (`mysql://user:password@host:3306/db?ssl-mode=required`), TLS modes, client certificates.
|
||||
- age encryption, zstd compression, Google Cloud Storage, FTPS, extra storage copies.
|
||||
- `verify`, `list`, `delete`, `prune`, `reconcile`, `config`, `health` commands; backup index with sha256 per backup.
|
||||
- Slack, Discord, ntfy, webhook and healthcheck notifications; Prometheus metrics in daemon mode.
|
||||
- `migrate -a`, and migrate without an intermediate file.
|
||||
@@ -6,21 +6,22 @@ services:
|
||||
# for a list of available releases.
|
||||
image: jkaninda/mysql-bkup
|
||||
container_name: mysql-bkup
|
||||
command: backup --storage s3 -d my-database"
|
||||
command: backup --storage s3 -d my-database
|
||||
environment:
|
||||
- DB_PORT=3306
|
||||
- DB_HOST=mysql
|
||||
- DB_NAME=database
|
||||
- DB_USERNAME=username
|
||||
- DB_USER=username
|
||||
- DB_PASSWORD=password
|
||||
## AWS configurations
|
||||
- AWS_S3_ENDPOINT=https://s3.amazonaws.com
|
||||
- AWS_S3_BUCKET_NAME=backup
|
||||
- AWS_REGION="us-west-2"
|
||||
- AWS_REGION=us-west-2
|
||||
- AWS_ACCESS_KEY=xxxx
|
||||
- AWS_SECRET_KEY=xxxxx
|
||||
## In case you are using S3 alternative such as Minio and your Minio instance is not secured, you change it to true
|
||||
- AWS_DISABLE_SSL="false"
|
||||
- AWS_DISABLE_SSL=false
|
||||
- AWS_FORCE_PATH_STYLE=true # true for S3 alternative such as Minio
|
||||
# mysql-bkup container must be connected to the same network with your database
|
||||
networks:
|
||||
- web
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
version: "3"
|
||||
services:
|
||||
mysql-bkup:
|
||||
# In production, it is advised to lock your image tag to a proper
|
||||
# release version instead of using `latest`.
|
||||
image: jkaninda/mysql-bkup
|
||||
container_name: mysql-bkup
|
||||
command: backup --dbname database_name --mode scheduled --period "0 1 * * *"
|
||||
command: backup --dbname database_name
|
||||
volumes:
|
||||
- ./backup:/backup
|
||||
environment:
|
||||
- DB_PORT=3306
|
||||
- DB_HOST=mysql
|
||||
- DB_USERNAME=userName
|
||||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
- DB_USER=userName
|
||||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
# See: https://jkaninda.github.io/mysql-bkup/reference/#predefined-schedules
|
||||
- BACKUP_CRON_EXPRESSION=@daily #@every 5m|@weekly | @monthly |0 1 * * *
|
||||
@@ -6,21 +6,24 @@ services:
|
||||
# for a list of available releases.
|
||||
image: jkaninda/mysql-bkup
|
||||
container_name: mysql-bkup
|
||||
command: backup --storage s3 -d my-database --mode scheduled --period "0 1 * * *"
|
||||
command: backup --storage s3 -d my-database
|
||||
environment:
|
||||
- DB_PORT=3306
|
||||
- DB_HOST=mysql
|
||||
- DB_NAME=database
|
||||
- DB_USERNAME=username
|
||||
- DB_USER=username
|
||||
- DB_PASSWORD=password
|
||||
## AWS configurations
|
||||
- AWS_S3_ENDPOINT=https://s3.amazonaws.com
|
||||
- AWS_S3_BUCKET_NAME=backup
|
||||
- AWS_REGION="us-west-2"
|
||||
- AWS_REGION=us-west-2
|
||||
- AWS_ACCESS_KEY=xxxx
|
||||
- AWS_SECRET_KEY=xxxxx
|
||||
## In case you are using S3 alternative such as Minio and your Minio instance is not secured, you change it to true
|
||||
- AWS_DISABLE_SSL="false"
|
||||
- AWS_DISABLE_SSL=false
|
||||
- AWS_FORCE_PATH_STYLE=true # true for S3 alternative such as Minio
|
||||
# See: https://jkaninda.github.io/mysql-bkup/reference/#predefined-schedules
|
||||
- BACKUP_CRON_EXPRESSION=@daily #@every 5m|@weekly | @monthly |0 1 * * *
|
||||
# mysql-bkup container must be connected to the same network with your database
|
||||
networks:
|
||||
- web
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
version: "3"
|
||||
services:
|
||||
mysql-bkup:
|
||||
image: jkaninda/mysql-bkup
|
||||
@@ -9,5 +8,5 @@ services:
|
||||
environment:
|
||||
- DB_PORT=3306
|
||||
- DB_HOST=mysql
|
||||
- DB_USERNAME=userName
|
||||
- DB_USER=userName
|
||||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
@@ -27,7 +27,7 @@ spec:
|
||||
value: ""
|
||||
- name: DB_NAME
|
||||
value: "dbname"
|
||||
- name: DB_USERNAME
|
||||
- name: DB_USER
|
||||
value: "username"
|
||||
# Please use secret!
|
||||
- name: DB_PASSWORD
|
||||
@@ -44,4 +44,6 @@ spec:
|
||||
value: "xxxx"
|
||||
- name: AWS_DISABLE_SSL
|
||||
value: "false"
|
||||
- name: AWS_FORCE_PATH_STYLE
|
||||
value: "true"
|
||||
restartPolicy: Never
|
||||
@@ -1,21 +1,102 @@
|
||||
module github.com/jkaninda/mysql-bkup
|
||||
|
||||
go 1.22.5
|
||||
|
||||
require github.com/spf13/pflag v1.0.5
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go v1.55.3
|
||||
github.com/bramvdbogaerde/go-scp v1.5.0
|
||||
github.com/hpcloud/tail v1.0.0
|
||||
github.com/spf13/cobra v1.8.0
|
||||
golang.org/x/crypto v0.18.0
|
||||
filippo.io/age v1.3.2
|
||||
github.com/jkaninda/bkupkit v0.0.2
|
||||
)
|
||||
|
||||
require (
|
||||
cel.dev/expr v0.25.2 // indirect
|
||||
cloud.google.com/go v0.123.0 // indirect
|
||||
cloud.google.com/go/auth v0.18.2 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
cloud.google.com/go/iam v1.5.3 // indirect
|
||||
cloud.google.com/go/monitoring v1.24.3 // indirect
|
||||
cloud.google.com/go/storage v1.60.0 // indirect
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
filippo.io/hpke v0.4.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.1 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.4.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.47.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.33.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.20.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.43 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.10.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.38.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.51.4 // indirect
|
||||
github.com/aws/smithy-go v1.28.5 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-logr/logr v1.4.4 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
|
||||
github.com/goposta/posta-go v0.1.0 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
golang.org/x/sys v0.22.0 // indirect
|
||||
gopkg.in/fsnotify.v1 v1.4.7 // indirect
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
|
||||
github.com/jkaninda/encryptor v0.3.0 // indirect
|
||||
github.com/jkaninda/go-storage v0.2.0 // indirect
|
||||
github.com/jkaninda/okapi v0.11.0 // indirect
|
||||
github.com/jlaffaye/ftp v0.2.0 // indirect
|
||||
github.com/klauspost/compress v1.20.3 // indirect
|
||||
github.com/klauspost/pgzip v1.3.0 // indirect
|
||||
github.com/kr/fs v0.1.0 // indirect
|
||||
github.com/pkg/sftp v1.13.10 // indirect
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/spf13/cobra v1.10.2 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.7.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
|
||||
go.opentelemetry.io/otel v1.45.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.45.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.45.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.45.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.45.0 // indirect
|
||||
golang.org/x/crypto v0.57.0 // indirect
|
||||
golang.org/x/net v0.60.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
google.golang.org/api v0.267.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
google.golang.org/grpc v1.83.1 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||
gopkg.in/mail.v2 v2.3.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -1,38 +1,247 @@
|
||||
github.com/aws/aws-sdk-go v1.55.3 h1:0B5hOX+mIx7I5XPOrjrHlKSDQV/+ypFZpIHOx5LOk3E=
|
||||
github.com/aws/aws-sdk-go v1.55.3/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
|
||||
github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
|
||||
github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
|
||||
github.com/bramvdbogaerde/go-scp v1.5.0 h1:a9BinAjTfQh273eh7vd3qUgmBC+bx+3TRDtkZWmIpzM=
|
||||
github.com/bramvdbogaerde/go-scp v1.5.0/go.mod h1:on2aH5AxaFb2G0N5Vsdy6B0Ml7k9HuHSwfo1y0QzAbQ=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d h1:Blprhc2SbChNZtWcU+BLTM4YdoqYAS9V7cJgOwJKyAs=
|
||||
c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
|
||||
cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs=
|
||||
cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
|
||||
cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
|
||||
cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
|
||||
cloud.google.com/go/logging v1.13.1 h1:O7LvmO0kGLaHY/gq8cV7T0dyp6zJhYAOtZPX4TF3QtY=
|
||||
cloud.google.com/go/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw=
|
||||
cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8=
|
||||
cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk=
|
||||
cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE=
|
||||
cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI=
|
||||
cloud.google.com/go/storage v1.60.0 h1:oBfZrSOCimggVNz9Y/bXY35uUcts7OViubeddTTVzQ8=
|
||||
cloud.google.com/go/storage v1.60.0/go.mod h1:q+5196hXfejkctrnx+VYU8RKQr/L3c0cBIlrjmiAKE0=
|
||||
cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U=
|
||||
cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s=
|
||||
filippo.io/age v1.3.2 h1:r6RSZLFSMm6rzKepZ7ZAYkKCu14f3/Me8c7uKYh7C8c=
|
||||
filippo.io/age v1.3.2/go.mod h1:TH/Yr2sSRhCKbaH4XPxpUV0Us8Gv6txYUpiZQWz8Evk=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
|
||||
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.9.0 h1:OVoM452qUFBrX+URdH3VpR299ma4kfom0yB0URYky9g=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.9.0/go.mod h1:kUjrAo8bgEwLeZ/CmHqNl3Z/kPm7y6FKfxxK0izYUg4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.0 h1:LR0kAX9ykz8G4YgLCaRDVJ3+n43R8MneB5dTy2konZo=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.0/go.mod h1:DWAciXemNf++PQJLeXUB4HHH5OpsAh12HZnu2wXE1jA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.1 h1:lhZdRq7TIx0GJQvSyX2Si406vrYsov2FXGp/RnSEtcs=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.1/go.mod h1:8cl44BDmi+effbARHMQjgOKA2AYvcohNm7KEt42mSV8=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 h1:l7+6kwRMJNwdCvYdDl7Eax+wzEYHSnNY7zrrfbhDdTA=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
|
||||
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
|
||||
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
|
||||
github.com/aws/aws-sdk-go-v2 v1.47.3 h1:HRoT/MkNi3bjO5xF4dCzLzvVmkvRGHj3/cLINDnJejk=
|
||||
github.com/aws/aws-sdk-go-v2 v1.47.3/go.mod h1:xSl+OSI5ZUw6bSMupWc218zE5Pl+65MCCu3ySWFY99A=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.22 h1:oiEmPz/YMMeh/JvAE8xSxNCIsvWE6lpFe4ZPlyFykQI=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.22/go.mod h1:QWUDuRi9PFjBHIU2iH1Pkldbe7zqLfXJC4wWLbF8nuQ=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.33.4 h1:FzvkXKSzwqHni4U7nDigHg4jjtqMpVUuHgmZfSoJVQ0=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.33.4/go.mod h1:VZqGZnZsCWVfK/iGPptJIyNIX3XEX6iQU2Rel4sLrr8=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.20.9 h1:SNO55PnWhaDafsOcv16lCA0kdoFMl/YlegWoVsU02s0=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.20.9/go.mod h1:+2reOTRtdD3VSBqpRDNwrZIYy8yMCYa7oK3WKTJ/I1I=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.3 h1:gr3b87OLB29jymWPzp/CAyXwQRJVE+sC4Wm0qBIKHsk=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.3/go.mod h1:BRjf4L/Yx2hY54o+ZibPbLJyMMeSwqYQXwcJfqW66a8=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.43 h1:Fhx4uwBshQF0+jmhV/FKDM4LPj7L9iUy5n3MvLxLy00=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.43/go.mod h1:4Tyfc1eIPEyrAMysLCVkcYL1Hm/hkGWbElLo8pejHPo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.6 h1:CqL8NN3p4I0JTwwV5MEoatIl4Y2amZ2X9dRD+0hbRL0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.6/go.mod h1:exdJ32hMqXl0Wyo6NwqEC5yIhtua3+PDaKE8P29m4qE=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.6 h1:FpBq24TG6WVSObTNhQOv7Q0088rounUUlwn3zlNItkM=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.6/go.mod h1:ONzdPVYC95oBOu0VWNEsKEntcoemP3xVKjT+qWml1s4=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6 h1:Qmn60RWuk9+bd2ddvJJqwf3Cpac3VvGMOdbGemtUhX4=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6/go.mod h1:h/YJhe4AVyQxEUMluph10iqV7mjajH7A9L+BHF5ppis=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21 h1:e6YW5uME30TkrElR+4/xcY7NB6CCj2dcv7vtmGXhfhQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21/go.mod h1:obpHAMP693+f3/A8PkLhIqfK1C+NWWn8zXzqD3OH520=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.7 h1:CwvFlKw0uUScfgOMflXSRZa+ayXgOf6wk7lhyMemQYU=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.7/go.mod h1:4SbD8BB+p+QEnG0aX6DoWwrtk9SufyYXRsUv0nve8LE=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6 h1:LHHdobjo5le1sjJdolitV7uVvYg3wkaeM1Ju2fDgeZQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6/go.mod h1:CBGB0evAiaMD3Lk4wPO7LQCcCZLuhzL5Qf/wfEUHQ5w=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.6 h1:mYViDr2azcgxOpRrObZOl631bvavqILoE/L+Kxgbwys=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.6/go.mod h1:OXVdAo9jNhggB+hJRGOyy7WolGTKB6Rg7IIulDeSzPM=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.3 h1:NyeuyXkZL476uoAowNActm4XJO1QPKf7aassgXHpiqA=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.3/go.mod h1:GfoBeKMDJecgRUsGf/0RGdx0NJAh81ObTd922h8pQys=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.10.4 h1:tvpDjuUvgKURf3Yk4hOaGbZ4QdY+070OWcA5d6LUv2A=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.10.4/go.mod h1:3ibVKg5Ng82Zby8++jELTD2nHxiPk2DJRLEeW/LKp8M=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.38.4 h1:ChCtagixSFOxltv0o6E3NT8F7ksvZtZGPwUBynO5Nmk=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.38.4/go.mod h1:+FEO9jrVMpc9oN3CdAxJbtlQEu3z8UddDPSedpeM3PY=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.4 h1:j6c+HA4AoS0EBEjArPjavFEBrucJEryfrQU0TffmACk=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.4/go.mod h1:RhghaXUk9q66VtOQUX1FdPddQuHnqQZzbB0fEYXmPRA=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.51.4 h1:OKEfbf2tcfsF2pBk/S9QqNpo6alaAtiBs4hysCsBjPw=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.51.4/go.mod h1:mCd6qGo9b8hTtfjgsmNkFlzwjyce4owd7HDVFnCLXhM=
|
||||
github.com/aws/smithy-go v1.28.5 h1:OqbqKtzY9NUnr2yAe9+hiTdFb20tNaK/lI0PMOGXX9k=
|
||||
github.com/aws/smithy-go v1.28.5/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
|
||||
github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
|
||||
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
|
||||
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
|
||||
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
|
||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8=
|
||||
github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
|
||||
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
|
||||
github.com/goposta/posta-go v0.1.0 h1:UkMRZ2FAxUbjIZRiT9GIdrm4ypFtngKI/hZeC9hYB9Y=
|
||||
github.com/goposta/posta-go v0.1.0/go.mod h1:IjYW+tr9L+3NAzPWScOKvxdi41x6JBL1ju63usJLgRM=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
|
||||
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/jkaninda/bkupkit v0.0.2 h1:3a5J/8qwXX7TWYBwIX57OQvUQCqo3er/JZZwvgU8I1g=
|
||||
github.com/jkaninda/bkupkit v0.0.2/go.mod h1:TB2HVP8jRau/TdwfsO4QOgJY4Vi4h6HX+BlxJzXdcIg=
|
||||
github.com/jkaninda/encryptor v0.3.0 h1:m7r7ROyjXtVXpFTtdGAhHdcPLDD+6kb0eaTF9cY4KM4=
|
||||
github.com/jkaninda/encryptor v0.3.0/go.mod h1:ktF+sv4yLWa97xQt6Quhr1AWKwTnE7/HHEb6JJ3O7QU=
|
||||
github.com/jkaninda/go-storage v0.2.0 h1:EjhIcu+zFkqTqnCleJqyS2TJfIC8U+V+Imw/qN3OVJM=
|
||||
github.com/jkaninda/go-storage v0.2.0/go.mod h1:uJOFRtwwfq9yhj/yG3we5OTTg3JGeBv24/SrgmsSPKQ=
|
||||
github.com/jkaninda/okapi v0.11.0 h1:73Ke2BJF5TTYG0DLKkFnSKrAP87bzErRQ6tOxF+DHF4=
|
||||
github.com/jkaninda/okapi v0.11.0/go.mod h1:/Bt3U6MpUaGdb7iPKjyRpqFhv9/AIj/SReNbRjGQ1mw=
|
||||
github.com/jlaffaye/ftp v0.2.0 h1:lXNvW7cBu7R/68bknOX3MrRIIqZ61zELs1P2RAiA3lg=
|
||||
github.com/jlaffaye/ftp v0.2.0/go.mod h1:is2Ds5qkhceAPy2xD6RLI6hmp/qysSoymZ+Z2uTnspI=
|
||||
github.com/klauspost/compress v1.20.3 h1:zWNoFqq/lSuYQRim3dUXlXeA0IqwuZGicdG5ZTfY5j4=
|
||||
github.com/klauspost/compress v1.20.3/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/klauspost/pgzip v1.3.0 h1:8t+KjjzD/0n/8vNlJGGZmTeK4njuwffOQvJm8tOSc8o=
|
||||
github.com/klauspost/pgzip v1.3.0/go.mod h1:Xx7tAv5DH8ObLHUDQKXoE/nd8i/FYdNDTVMvi3SLTKI=
|
||||
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
|
||||
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
|
||||
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
||||
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
||||
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spiffe/go-spiffe/v2 v2.7.0 h1:uXe1MflJoHw58wAUvxVlcM7WpKtijWG7I1UidcGh6g4=
|
||||
github.com/spiffe/go-spiffe/v2 v2.7.0/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.44.0 h1:NmLfL734pJhM0JKaYd2Y28+nY9dPRWYAAbxhRCrKXPw=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.44.0/go.mod h1:tNAsgd8avTGke1+MndXlU5Cru4PQ9Ai/cCNWQv/ZJ/s=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||
go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
|
||||
go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.39.0 h1:5gn2urDL/FBnK8OkCfD1j3/ER79rUuTYmCvlXBKeYL8=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.39.0/go.mod h1:0fBG6ZJxhqByfFZDwSwpZGzJU671HkwpWaNe2t4VUPI=
|
||||
go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
|
||||
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
|
||||
go.opentelemetry.io/otel/metric/x v0.67.0 h1:PcicCNZFkZ4bXfSooXdo3WN7RBOVOtjVdo1wD358Uns=
|
||||
go.opentelemetry.io/otel/metric/x v0.67.0/go.mod h1:FBjCWZe6wgcqxcMtjdGiClDKXb2YxxXii0CXftE4QtI=
|
||||
go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw=
|
||||
go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA=
|
||||
go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
|
||||
go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
|
||||
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
||||
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/api v0.267.0 h1:w+vfWPMPYeRs8qH1aYYsFX68jMls5acWl/jocfLomwE=
|
||||
google.golang.org/api v0.267.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0=
|
||||
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM=
|
||||
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y=
|
||||
google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk=
|
||||
gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,851 @@
|
||||
//go:build integration
|
||||
|
||||
// Integration tests: real MySQL / MariaDB servers in Docker, the real client
|
||||
// tools, the whole bkupkit pipeline through cli.App.Run.
|
||||
//
|
||||
// The client tools run in containers too: shim scripts named mysql, mysqldump,
|
||||
// mariadb and mariadb-dump are put first in PATH and `docker run -i` the
|
||||
// official images (or IT_CLIENT_IMAGE, e.g. the mysql-bkup image itself). The
|
||||
// private TMP_DIR holding the option files is bind-mounted at the same path.
|
||||
//
|
||||
// go test -tags integration -v -run . ./...
|
||||
// IT_CLIENT_IMAGE=jkaninda/mysql-bkup:test go test -tags integration ./...
|
||||
// IT_SKIP_MYSQL80=1 ... skip the MySQL 8.0 server
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/jkaninda/bkupkit/cli"
|
||||
"github.com/jkaninda/mysql-bkup/internal/mysql"
|
||||
)
|
||||
|
||||
const (
|
||||
rootPW = "r00t-pw"
|
||||
// appPW exercises option-file quoting.
|
||||
appPW = `p"a#s s\w'd$x`
|
||||
)
|
||||
|
||||
var (
|
||||
itNetwork string
|
||||
itTmp string // TMP_DIR shared with the client containers
|
||||
itShims string
|
||||
itSkip string
|
||||
servers []*dbServer
|
||||
minioPort string
|
||||
)
|
||||
|
||||
type dbServer struct {
|
||||
name string // container name and DB_HOST
|
||||
image string
|
||||
client string // client binary inside the server image
|
||||
mariadb bool
|
||||
}
|
||||
|
||||
// Miabi takes the LAST match of this regex in the output as the artifact.
|
||||
var miabiDB = regexp.MustCompile(`[\w.\-]+\.(?:sql|archive)\.gz(?:\.gpg)?`)
|
||||
|
||||
func docker(args ...string) (string, error) {
|
||||
// Stdout only on success: "docker run" prints image pull progress on stderr,
|
||||
// which would otherwise end up in the container id on a runner without the image.
|
||||
cmd := exec.Command("docker", args...)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
out = append(out, stderr.Bytes()...)
|
||||
}
|
||||
return strings.TrimSpace(string(out)), err
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
if _, err := exec.LookPath("docker"); err != nil {
|
||||
itSkip = "docker not installed"
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
if _, err := docker("info"); err != nil {
|
||||
itSkip = "docker not running"
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
code := func() int {
|
||||
suffix := fmt.Sprintf("%d", time.Now().UnixNano()%1e9)
|
||||
itNetwork = "mysqlbkup-it-" + suffix
|
||||
if out, err := docker("network", "create", itNetwork); err != nil {
|
||||
fmt.Fprintln(os.Stderr, out)
|
||||
return 1
|
||||
}
|
||||
defer docker("network", "rm", itNetwork)
|
||||
var err error
|
||||
if itTmp, err = os.MkdirTemp("", "mysqlbkup-it-tmp-"); err != nil {
|
||||
return 1
|
||||
}
|
||||
defer os.RemoveAll(itTmp)
|
||||
// macOS: /var/folders is a symlink to /private/var/folders; mount the real path.
|
||||
if p, err := filepath.EvalSymlinks(itTmp); err == nil {
|
||||
itTmp = p
|
||||
}
|
||||
if itShims, err = os.MkdirTemp("", "mysqlbkup-it-bin-"); err != nil {
|
||||
return 1
|
||||
}
|
||||
defer os.RemoveAll(itShims)
|
||||
writeShims()
|
||||
|
||||
servers = []*dbServer{
|
||||
{name: "it-mysql84-" + suffix, image: "mysql:8.4", client: "mysql"},
|
||||
{name: "it-mariadb118-" + suffix, image: "mariadb:11.8", client: "mariadb", mariadb: true},
|
||||
}
|
||||
if os.Getenv("IT_SKIP_MYSQL80") == "" {
|
||||
servers = append(servers, &dbServer{name: "it-mysql80-" + suffix, image: "mysql:8.0", client: "mysql"})
|
||||
}
|
||||
for _, s := range servers {
|
||||
args := []string{"run", "-d", "--rm", "--name", s.name, "--network", itNetwork}
|
||||
if s.mariadb {
|
||||
args = append(args, "-e", "MARIADB_ROOT_PASSWORD="+rootPW)
|
||||
} else {
|
||||
args = append(args, "-e", "MYSQL_ROOT_PASSWORD="+rootPW)
|
||||
}
|
||||
if out, err := docker(append(args, s.image)...); err != nil {
|
||||
fmt.Fprintln(os.Stderr, out)
|
||||
return 1
|
||||
}
|
||||
defer docker("rm", "-f", s.name)
|
||||
}
|
||||
minio := "it-minio-" + suffix
|
||||
if out, err := docker("run", "-d", "--rm", "--name", minio, "-p", "127.0.0.1::9000",
|
||||
"-e", "MINIO_ROOT_USER=minioadmin", "-e", "MINIO_ROOT_PASSWORD=minioadmin",
|
||||
"pgsty/minio:latest", "server", "/data"); err != nil {
|
||||
fmt.Fprintln(os.Stderr, out)
|
||||
return 1
|
||||
}
|
||||
defer docker("rm", "-f", minio)
|
||||
for _, s := range servers {
|
||||
if err := s.wait(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
out, err := docker("port", minio, "9000/tcp")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, out)
|
||||
return 1
|
||||
}
|
||||
minioPort = out[strings.LastIndex(out, ":")+1:]
|
||||
deadline := time.Now().Add(60 * time.Second)
|
||||
for {
|
||||
out, err = docker("exec", minio, "sh", "-c", "mc alias set l http://127.0.0.1:9000 minioadmin minioadmin && mc mb --ignore-existing l/backups")
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
fmt.Fprintln(os.Stderr, "minio:", out)
|
||||
return 1
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
minioContainer = minio
|
||||
return m.Run()
|
||||
}()
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
var minioContainer string
|
||||
|
||||
func writeShims() {
|
||||
imgs := map[string]string{"mysql": "mysql:8.4", "mysqldump": "mysql:8.4", "mariadb": "mariadb:11.8", "mariadb-dump": "mariadb:11.8"}
|
||||
for bin, img := range imgs {
|
||||
if ci := os.Getenv("IT_CLIENT_IMAGE"); ci != "" {
|
||||
img = ci
|
||||
}
|
||||
sh := fmt.Sprintf("#!/bin/sh\nexec docker run --rm -i --network %s -v %s:%s --entrypoint %s %s \"$@\"\n", itNetwork, itTmp, itTmp, bin, img)
|
||||
if err := os.WriteFile(filepath.Join(itShims, bin), []byte(sh), 0o755); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sql runs statements as root inside the server container.
|
||||
func (s *dbServer) sql(stmts string) (string, error) {
|
||||
cmd := exec.Command("docker", "exec", "-i", "-e", "MYSQL_PWD="+rootPW, s.name, s.client, "-uroot", "-h127.0.0.1", "-N", "-B")
|
||||
cmd.Stdin = strings.NewReader(stmts)
|
||||
out, err := cmd.CombinedOutput()
|
||||
return strings.TrimSpace(string(out)), err
|
||||
}
|
||||
|
||||
func (s *dbServer) wait() error {
|
||||
deadline := time.Now().Add(3 * time.Minute)
|
||||
for {
|
||||
// TCP only answers once the image's init server is gone.
|
||||
if _, err := s.sql("SELECT 1"); err == nil {
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
logs, _ := docker("logs", s.name)
|
||||
return fmt.Errorf("%s not ready:\n%s", s.name, logs)
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *dbServer) mustSQL(t *testing.T, stmts string) string {
|
||||
t.Helper()
|
||||
out, err := s.sql(stmts)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v\n%s", s.name, err, out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const fixture = `
|
||||
DROP DATABASE IF EXISTS %[1]s;
|
||||
CREATE DATABASE %[1]s CHARACTER SET utf8mb4;
|
||||
USE %[1]s;
|
||||
CREATE TABLE products (id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100), price DECIMAL(10,2), data BLOB, created DATETIME);
|
||||
CREATE TABLE audit (id INT PRIMARY KEY AUTO_INCREMENT, product_id INT, note TEXT);
|
||||
CREATE TRIGGER trg_products AFTER INSERT ON products FOR EACH ROW INSERT INTO audit (product_id, note) VALUES (NEW.id, 'created');
|
||||
CREATE PROCEDURE p_count() SELECT COUNT(*) FROM products;
|
||||
CREATE VIEW v_products AS SELECT id, name FROM products;
|
||||
CREATE EVENT ev_cleanup ON SCHEDULE EVERY 1 DAY DO DELETE FROM audit WHERE id < 0;
|
||||
INSERT INTO products (name, price, data, created) VALUES
|
||||
('café 😀', 9.99, X'00FF0A0D27225C', '2026-01-02 03:04:05'),
|
||||
('it''s "quoted" \\ back', 0.01, NULL, NULL),
|
||||
('tab\tnew\nline', 12345678.90, REPEAT(X'AB', 60000), '1999-12-31 23:59:59');
|
||||
INSERT INTO products (name, price) SELECT CONCAT('bulk-', a.n * 10 + b.n), a.n + b.n FROM
|
||||
(SELECT 0 n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) a,
|
||||
(SELECT 0 n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) b;
|
||||
`
|
||||
|
||||
func (s *dbServer) loadFixture(t *testing.T, db string) {
|
||||
t.Helper()
|
||||
s.mustSQL(t, fmt.Sprintf(fixture, db))
|
||||
}
|
||||
|
||||
// fingerprint summarises the data and objects of db.
|
||||
func (s *dbServer) fingerprint(t *testing.T, db string) string {
|
||||
t.Helper()
|
||||
q := fmt.Sprintf(`SET SESSION group_concat_max_len = 100000000;
|
||||
SELECT COUNT(*), MD5(GROUP_CONCAT(CONCAT_WS('|', id, name, price, HEX(data), created) ORDER BY id SEPARATOR ';')) FROM %[1]s.products;
|
||||
SELECT COUNT(*) FROM %[1]s.audit;
|
||||
SELECT COUNT(*) FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA = '%[1]s';
|
||||
SELECT COUNT(*) FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = '%[1]s';
|
||||
SELECT COUNT(*) FROM information_schema.VIEWS WHERE TABLE_SCHEMA = '%[1]s';
|
||||
SELECT COUNT(*) FROM information_schema.EVENTS WHERE EVENT_SCHEMA = '%[1]s';`, db)
|
||||
return s.mustSQL(t, q)
|
||||
}
|
||||
|
||||
func needDocker(t *testing.T) {
|
||||
t.Helper()
|
||||
if itSkip != "" {
|
||||
t.Skip(itSkip)
|
||||
}
|
||||
}
|
||||
|
||||
// run executes the CLI in-process and returns its combined output.
|
||||
func run(t *testing.T, args ...string) (string, int) {
|
||||
t.Helper()
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldOut, oldErr := os.Stdout, os.Stderr
|
||||
os.Stdout, os.Stderr = w, w
|
||||
var buf bytes.Buffer
|
||||
done := make(chan struct{})
|
||||
go func() { _, _ = io.Copy(&buf, r); close(done) }()
|
||||
code := (&cli.App{Engine: mysql.New(), Version: "it"}).Run(args)
|
||||
_ = w.Close()
|
||||
<-done
|
||||
os.Stdout, os.Stderr = oldOut, oldErr
|
||||
return buf.String(), code
|
||||
}
|
||||
|
||||
func mustRun(t *testing.T, args ...string) string {
|
||||
t.Helper()
|
||||
out, code := run(t, args...)
|
||||
if code != 0 {
|
||||
t.Fatalf("%v: exit %d\n%s", args, code, out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// setEnv sets the common environment for s and returns the local storage dir.
|
||||
func setEnv(t *testing.T, s *dbServer, user, pw string) string {
|
||||
t.Helper()
|
||||
store := t.TempDir()
|
||||
t.Setenv("PATH", itShims+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
t.Setenv("TMP_DIR", itTmp)
|
||||
t.Setenv("STORAGE_PATH", store)
|
||||
t.Setenv("HEALTH_FILE", filepath.Join(t.TempDir(), "health.json"))
|
||||
t.Setenv("TEMPLATES_DIR", "")
|
||||
t.Setenv("DB_HOST", s.name)
|
||||
t.Setenv("DB_PORT", "3306")
|
||||
t.Setenv("DB_USERNAME", user)
|
||||
t.Setenv("DB_PASSWORD", pw)
|
||||
return store
|
||||
}
|
||||
|
||||
// artifact returns the last Miabi regex match and checks the file exists in dir.
|
||||
func artifact(t *testing.T, out, dir string) string {
|
||||
t.Helper()
|
||||
m := miabiDB.FindAllString(out, -1)
|
||||
if len(m) == 0 {
|
||||
t.Fatalf("no artifact name in output:\n%s", out)
|
||||
}
|
||||
last := m[len(m)-1]
|
||||
if dir != "" {
|
||||
if _, err := os.Stat(filepath.Join(dir, last)); err != nil {
|
||||
t.Fatalf("last match %q is not the stored artifact: %v\n%s", last, err, out)
|
||||
}
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
func assertTmpClean(t *testing.T) {
|
||||
t.Helper()
|
||||
if ents, _ := os.ReadDir(itTmp); len(ents) != 0 {
|
||||
var names []string
|
||||
for _, e := range ents {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
t.Fatalf("TMP_DIR not empty: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func forEachServer(t *testing.T, fn func(t *testing.T, s *dbServer)) {
|
||||
needDocker(t)
|
||||
for _, s := range servers {
|
||||
t.Run(strings.SplitN(strings.TrimPrefix(s.name, "it-"), "-", 2)[0], func(t *testing.T) { fn(t, s) })
|
||||
}
|
||||
}
|
||||
|
||||
// TestSingleDatabase: Miabi's argv (backup -d db, restore -d db -f file),
|
||||
// gzip default, restore after data loss, the client chosen per flavour.
|
||||
func TestSingleDatabase(t *testing.T) {
|
||||
forEachServer(t, func(t *testing.T, s *dbServer) {
|
||||
s.loadFixture(t, "shop")
|
||||
want := s.fingerprint(t, "shop")
|
||||
store := setEnv(t, s, "root", rootPW)
|
||||
|
||||
out := mustRun(t, "backup", "-d", "shop")
|
||||
file := artifact(t, out, store)
|
||||
if !strings.HasSuffix(file, ".sql.gz") || !strings.HasPrefix(file, "shop_") {
|
||||
t.Fatalf("artifact %q", file)
|
||||
}
|
||||
wantClient := "client=mysqldump"
|
||||
if s.mariadb {
|
||||
wantClient = "client=mariadb-dump"
|
||||
}
|
||||
if !strings.Contains(out, wantClient) {
|
||||
t.Fatalf("expected %s:\n%s", wantClient, out)
|
||||
}
|
||||
assertTmpClean(t)
|
||||
|
||||
s.mustSQL(t, "DROP DATABASE shop")
|
||||
mustRun(t, "restore", "-d", "shop", "-f", file)
|
||||
if got := s.fingerprint(t, "shop"); got != want {
|
||||
t.Fatalf("restored data differs:\n got %s\nwant %s", got, want)
|
||||
}
|
||||
assertTmpClean(t)
|
||||
|
||||
// verify and list work without a database connection.
|
||||
mustRun(t, "verify", "-f", file)
|
||||
if out := mustRun(t, "list"); !strings.Contains(out, file) {
|
||||
t.Fatalf("list:\n%s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestNonRootUser: a user with only database privileges (no PROCESS, no
|
||||
// global SELECT) and a password that needs option-file quoting.
|
||||
func TestNonRootUser(t *testing.T) {
|
||||
forEachServer(t, func(t *testing.T, s *dbServer) {
|
||||
s.mustSQL(t, fmt.Sprintf(`DROP USER IF EXISTS 'app'@'%%'; CREATE USER 'app'@'%%' IDENTIFIED BY '%s';
|
||||
DROP DATABASE IF EXISTS appdb; CREATE DATABASE appdb; GRANT ALL ON appdb.* TO 'app'@'%%';
|
||||
CREATE TABLE appdb.items (id INT PRIMARY KEY, v VARCHAR(20)); INSERT INTO appdb.items VALUES (1,'a'),(2,'b');`,
|
||||
strings.NewReplacer(`\`, `\\`, `'`, `\'`).Replace(appPW)))
|
||||
store := setEnv(t, s, "app", appPW)
|
||||
out := mustRun(t, "backup", "-d", "appdb")
|
||||
file := artifact(t, out, store)
|
||||
s.mustSQL(t, "DELETE FROM appdb.items")
|
||||
mustRun(t, "restore", "-d", "appdb", "-f", file)
|
||||
if got := s.mustSQL(t, "SELECT COUNT(*) FROM appdb.items"); got != "2" {
|
||||
t.Fatalf("rows after restore: %s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestEncryption: GPG passphrase and age recipients, restored under another name.
|
||||
func TestEncryption(t *testing.T) {
|
||||
forEachServer(t, func(t *testing.T, s *dbServer) {
|
||||
s.loadFixture(t, "shop")
|
||||
want := s.fingerprint(t, "shop")
|
||||
store := setEnv(t, s, "root", rootPW)
|
||||
|
||||
t.Run("gpg", func(t *testing.T) {
|
||||
t.Setenv("GPG_PASSPHRASE", "correct horse")
|
||||
out := mustRun(t, "backup", "-d", "shop")
|
||||
file := artifact(t, out, store)
|
||||
if !strings.HasSuffix(file, ".sql.gz.gpg") {
|
||||
t.Fatalf("artifact %q", file)
|
||||
}
|
||||
s.mustSQL(t, "DROP DATABASE IF EXISTS shop_gpg")
|
||||
mustRun(t, "restore", "-d", "shop_gpg", "-f", file)
|
||||
if got := s.fingerprint(t, "shop_gpg"); got != want {
|
||||
t.Fatalf("restored data differs:\n got %s\nwant %s", got, want)
|
||||
}
|
||||
t.Setenv("GPG_PASSPHRASE", "wrong")
|
||||
if out, code := run(t, "restore", "-d", "shop_gpg", "-f", file); code == 0 {
|
||||
t.Fatalf("restore with a wrong passphrase succeeded:\n%s", out)
|
||||
}
|
||||
})
|
||||
t.Run("age", func(t *testing.T) {
|
||||
id, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("AGE_RECIPIENTS", id.Recipient().String())
|
||||
out := mustRun(t, "backup", "-d", "shop")
|
||||
m := regexp.MustCompile(`shop_[\w.\-]+\.sql\.gz\.age`).FindAllString(out, -1)
|
||||
if len(m) == 0 {
|
||||
t.Fatalf("no age artifact:\n%s", out)
|
||||
}
|
||||
file := m[len(m)-1]
|
||||
s.mustSQL(t, "DROP DATABASE IF EXISTS shop_age")
|
||||
t.Setenv("AGE_IDENTITY", id.String())
|
||||
mustRun(t, "restore", "-d", "shop_age", "-f", file)
|
||||
if got := s.fingerprint(t, "shop_age"); got != want {
|
||||
t.Fatalf("restored data differs:\n got %s\nwant %s", got, want)
|
||||
}
|
||||
})
|
||||
assertTmpClean(t)
|
||||
})
|
||||
}
|
||||
|
||||
// TestAllDatabases: one file per database (-a, --exclude-db) and one file for
|
||||
// everything (-A), restored without -d.
|
||||
func TestAllDatabases(t *testing.T) {
|
||||
forEachServer(t, func(t *testing.T, s *dbServer) {
|
||||
s.mustSQL(t, "DROP DATABASE IF EXISTS shop_gpg; DROP DATABASE IF EXISTS shop_age; DROP DATABASE IF EXISTS appdb; DROP DATABASE IF EXISTS skipme; CREATE DATABASE skipme;")
|
||||
s.loadFixture(t, "shop")
|
||||
s.loadFixture(t, "blog")
|
||||
wantShop, wantBlog := s.fingerprint(t, "shop"), s.fingerprint(t, "blog")
|
||||
store := setEnv(t, s, "root", rootPW)
|
||||
|
||||
out := mustRun(t, "backup", "-a", "--exclude-db", "skipme")
|
||||
ents, _ := os.ReadDir(store)
|
||||
seen := map[string]bool{}
|
||||
for _, e := range ents {
|
||||
if strings.HasSuffix(e.Name(), ".sql.gz") {
|
||||
seen[e.Name()[:strings.Index(e.Name(), "_2")]] = true
|
||||
}
|
||||
}
|
||||
if !seen["shop"] || !seen["blog"] || seen["skipme"] || seen["mysql"] || seen["sys"] {
|
||||
t.Fatalf("artifacts %v\n%s", seen, out)
|
||||
}
|
||||
artifact(t, out, store)
|
||||
|
||||
out = mustRun(t, "backup", "-A")
|
||||
file := artifact(t, out, store)
|
||||
if !strings.HasPrefix(file, mysql.AllInOneName+"_") {
|
||||
t.Fatalf("all-in-one artifact %q", file)
|
||||
}
|
||||
s.mustSQL(t, "DROP DATABASE shop; DROP DATABASE blog")
|
||||
t.Setenv("DB_NAME", "")
|
||||
mustRun(t, "restore", "-f", file)
|
||||
if got := s.fingerprint(t, "shop"); got != wantShop {
|
||||
t.Fatalf("shop differs:\n got %s\nwant %s", got, wantShop)
|
||||
}
|
||||
if got := s.fingerprint(t, "blog"); got != wantBlog {
|
||||
t.Fatalf("blog differs:\n got %s\nwant %s", got, wantBlog)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestUploadsAndCorruption: Miabi uploads of plain .sql and gzip .sql.gz
|
||||
// restore; corrupted inputs fail with a non-zero exit, and damaged or tampered
|
||||
// archives are caught before the client runs, so the database is untouched.
|
||||
func TestUploadsAndCorruption(t *testing.T) {
|
||||
forEachServer(t, func(t *testing.T, s *dbServer) {
|
||||
s.loadFixture(t, "shop")
|
||||
want := s.fingerprint(t, "shop")
|
||||
store := setEnv(t, s, "root", rootPW)
|
||||
out := mustRun(t, "backup", "-d", "shop", "--disable-compression")
|
||||
m := regexp.MustCompile(`shop_\d{8}_\d{6}(?:_\d+)?\.sql\b`).FindAllString(out, -1)
|
||||
if len(m) == 0 {
|
||||
t.Fatalf("no .sql artifact:\n%s", out)
|
||||
}
|
||||
plain, err := os.ReadFile(filepath.Join(store, m[len(m)-1]))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
write := func(name string, b []byte) {
|
||||
if err := os.WriteFile(filepath.Join(store, name), b, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("upload.sql", plain)
|
||||
var gz bytes.Buffer
|
||||
zw := gzip.NewWriter(&gz)
|
||||
_, _ = zw.Write(plain)
|
||||
_ = zw.Close()
|
||||
write("upload.sql.gz", gz.Bytes())
|
||||
write("truncated.sql.gz", gz.Bytes()[:gz.Len()/2])
|
||||
write("bad.sql", append(append([]byte{}, plain...), []byte("\nTHIS IS NOT SQL;\n")...))
|
||||
|
||||
// 1.x style absolute path: restore -f /backup/upload.sql.gz
|
||||
for _, f := range []string{"upload.sql", "upload.sql.gz", filepath.Join(store, "upload.sql.gz")} {
|
||||
s.mustSQL(t, "DROP DATABASE IF EXISTS shop_up")
|
||||
mustRun(t, "restore", "-d", "shop_up", "-f", f)
|
||||
if got := s.fingerprint(t, "shop_up"); got != want {
|
||||
t.Fatalf("%s: restored data differs", f)
|
||||
}
|
||||
}
|
||||
// A valid file with a bad statement cannot be caught before it runs.
|
||||
for _, f := range []string{"bad.sql", "missing.sql.gz"} {
|
||||
if out, code := run(t, "restore", "-d", "shop_bad", "-f", f); code == 0 {
|
||||
t.Fatalf("%s: restore succeeded:\n%s", f, out)
|
||||
}
|
||||
}
|
||||
// A damaged archive is verified first and never reaches the database.
|
||||
if out, code := run(t, "restore", "-d", "shop", "-f", "truncated.sql.gz"); code == 0 {
|
||||
t.Fatalf("truncated.sql.gz: restore succeeded:\n%s", out)
|
||||
}
|
||||
if got := s.fingerprint(t, "shop"); got != want {
|
||||
t.Fatal("truncated.sql.gz changed the database")
|
||||
}
|
||||
// Tampered GPG artifact.
|
||||
t.Setenv("GPG_PASSPHRASE", "pw")
|
||||
out = mustRun(t, "backup", "-d", "shop")
|
||||
file := artifact(t, out, store)
|
||||
b, _ := os.ReadFile(filepath.Join(store, file))
|
||||
b[len(b)/2] ^= 0xFF
|
||||
write("tampered.sql.gz.gpg", b)
|
||||
if out, code := run(t, "restore", "-d", "shop", "-f", "tampered.sql.gz.gpg"); code == 0 {
|
||||
t.Fatalf("tampered restore succeeded:\n%s", out)
|
||||
}
|
||||
if got := s.fingerprint(t, "shop"); got != want {
|
||||
t.Fatal("tampered.sql.gz.gpg changed the database")
|
||||
}
|
||||
assertTmpClean(t)
|
||||
})
|
||||
}
|
||||
|
||||
// dumpDatabases runs the server's own dump client (not mysql-bkup) with
|
||||
// --databases, as a dump made elsewhere and uploaded would be.
|
||||
func (s *dbServer) dumpDatabases(t *testing.T, dbs ...string) []byte {
|
||||
t.Helper()
|
||||
bin := "mysqldump"
|
||||
if s.mariadb {
|
||||
bin = "mariadb-dump"
|
||||
}
|
||||
args := append([]string{"exec", "-e", "MYSQL_PWD=" + rootPW, s.name, bin, "-uroot", "-h127.0.0.1",
|
||||
"--routines", "--triggers", "--events", "--add-drop-database", "--databases"}, dbs...)
|
||||
out, err := exec.Command("docker", args...).Output()
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", bin, err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mailer (re)creates the user mailer with privileges on dbs only (Miabi's
|
||||
// per-database users), recreating each database empty. Function and trigger
|
||||
// creation by non-SUPER users is allowed (see TestForeignDump).
|
||||
func (s *dbServer) mailer(t *testing.T, dbs ...string) {
|
||||
t.Helper()
|
||||
const user = "mailer"
|
||||
pw := strings.NewReplacer(`\`, `\\`, `'`, `\'`).Replace(appPW)
|
||||
q := fmt.Sprintf("DROP USER IF EXISTS '%[1]s'@'%%'; CREATE USER '%[1]s'@'%%' IDENTIFIED BY '%[2]s'; SET GLOBAL log_bin_trust_function_creators = 1;", user, pw)
|
||||
for _, db := range dbs {
|
||||
q += fmt.Sprintf(" DROP DATABASE IF EXISTS %[1]s; CREATE DATABASE %[1]s; GRANT ALL ON %[1]s.* TO '%[2]s'@'%%';", db, user)
|
||||
}
|
||||
s.mustSQL(t, q)
|
||||
}
|
||||
|
||||
func serverOf(t *testing.T, mariadb bool) *dbServer {
|
||||
t.Helper()
|
||||
needDocker(t)
|
||||
for _, s := range servers {
|
||||
if s.mariadb == mariadb {
|
||||
return s
|
||||
}
|
||||
}
|
||||
t.Skip("no such server")
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, dir, name string, b []byte) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, name), b, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossFlavourUploads: a mariadb-dump uploaded into MySQL (its sandbox
|
||||
// line, MariaDB definers) and a mysqldump uploaded into MariaDB, each into a
|
||||
// database of another name by a user with privileges on it only.
|
||||
func TestCrossFlavourUploads(t *testing.T) {
|
||||
maria, mysql := serverOf(t, true), serverOf(t, false)
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
src, dst *dbServer
|
||||
}{
|
||||
{"mariadb to mysql", maria, mysql},
|
||||
{"mysql to mariadb", mysql, maria},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
c.src.loadFixture(t, "posta")
|
||||
want := c.src.fingerprint(t, "posta")
|
||||
c.dst.mailer(t, "mail")
|
||||
store := setEnv(t, c.dst, "mailer", appPW)
|
||||
writeFile(t, store, "posta.sql", c.src.dumpDatabases(t, "posta"))
|
||||
out := mustRun(t, "restore", "-d", "mail", "-f", "posta.sql")
|
||||
if got := c.dst.fingerprint(t, "mail"); got != want {
|
||||
t.Fatalf("restored differs:\n got %s\nwant %s\n%s", got, want, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// classicDump is a hand-written dump in the style of the classicmodels sample
|
||||
// database: lowercase statements, USE without a semicolon, latin1.
|
||||
const classicDump = `/*!40101 SET NAMES utf8 */;
|
||||
/*!40101 SET SQL_MODE=''*/;
|
||||
create database if not exists ` + "`classicmodels`" + ` /*!40100 DEFAULT CHARACTER SET latin1 */;
|
||||
use classicmodels
|
||||
DROP TABLE IF EXISTS ` + "`customers`" + `;
|
||||
CREATE TABLE ` + "`customers`" + ` (
|
||||
` + "`customerNumber`" + ` int(11) NOT NULL,
|
||||
` + "`customerName`" + ` varchar(50) NOT NULL,
|
||||
PRIMARY KEY (` + "`customerNumber`" + `)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
|
||||
insert into ` + "`customers`" + `(` + "`customerNumber`,`customerName`" + `) values (103,'Atelier graphique'),(112,'Signal Gift Stores'),(114,'Australian Collectors, Co.');
|
||||
`
|
||||
|
||||
// TestHandWrittenUpload: the classicmodels-style file, gzipped, uploaded into
|
||||
// a database of another name.
|
||||
func TestHandWrittenUpload(t *testing.T) {
|
||||
forEachServer(t, func(t *testing.T, s *dbServer) {
|
||||
s.mailer(t, "blog")
|
||||
store := setEnv(t, s, "mailer", appPW)
|
||||
var gz bytes.Buffer
|
||||
zw := gzip.NewWriter(&gz)
|
||||
_, _ = zw.Write([]byte(classicDump))
|
||||
_ = zw.Close()
|
||||
writeFile(t, store, "classicmodels.sql.gz", gz.Bytes())
|
||||
mustRun(t, "restore", "-d", "blog", "-f", "classicmodels.sql.gz")
|
||||
if got := s.mustSQL(t, "SELECT COUNT(*) FROM blog.customers"); got != "3" {
|
||||
t.Fatalf("rows: %s", got)
|
||||
}
|
||||
if got := s.mustSQL(t, "SELECT COUNT(*) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = 'classicmodels'"); got != "0" {
|
||||
t.Fatal("the source database was created")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestDefinerModes: RESTORE_NO_DEFINER auto, true and false.
|
||||
func TestDefinerModes(t *testing.T) {
|
||||
s := serverOf(t, false)
|
||||
s.loadFixture(t, "posta")
|
||||
// mysqldump cannot dump a view whose definer does not exist.
|
||||
s.mustSQL(t, "DROP USER IF EXISTS 'ghost'@'%'; CREATE USER 'ghost'@'%'; CREATE DEFINER=`ghost`@`%` VIEW posta.v_ghost AS SELECT 1 AS one")
|
||||
s.mailer(t, "mail_strict")
|
||||
store := setEnv(t, s, "mailer", appPW)
|
||||
writeFile(t, store, "posta.sql", s.dumpDatabases(t, "posta"))
|
||||
viewDefiner := func(db string) string {
|
||||
return s.mustSQL(t, "SELECT DEFINER FROM information_schema.VIEWS WHERE TABLE_SCHEMA = '"+db+"' AND TABLE_NAME = 'v_ghost'")
|
||||
}
|
||||
|
||||
t.Setenv("RESTORE_NO_DEFINER", "false")
|
||||
if out, code := run(t, "restore", "-d", "mail_strict", "-f", "posta.sql"); code == 0 {
|
||||
t.Fatalf("restore with foreign definers as a database user succeeded:\n%s", out)
|
||||
}
|
||||
|
||||
setEnv(t, s, "root", rootPW)
|
||||
t.Setenv("STORAGE_PATH", store)
|
||||
s.mustSQL(t, "DROP DATABASE IF EXISTS mail_auto; DROP DATABASE IF EXISTS mail_strip")
|
||||
t.Setenv("RESTORE_NO_DEFINER", "auto")
|
||||
mustRun(t, "restore", "-d", "mail_auto", "-f", "posta.sql")
|
||||
if got := viewDefiner("mail_auto"); got != "ghost@%" {
|
||||
t.Fatalf("auto as root: definer %q", got)
|
||||
}
|
||||
t.Setenv("RESTORE_NO_DEFINER", "true")
|
||||
mustRun(t, "restore", "-d", "mail_strip", "-f", "posta.sql")
|
||||
if got := viewDefiner("mail_strip"); !strings.HasPrefix(got, "root@") {
|
||||
t.Fatalf("true as root: definer %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateToDatabaseUser: MySQL to MariaDB into a database of another name,
|
||||
// as a target user with privileges on it only (definers of the source).
|
||||
func TestMigrateToDatabaseUser(t *testing.T) {
|
||||
src, dst := serverOf(t, false), serverOf(t, true)
|
||||
src.loadFixture(t, "posta")
|
||||
want := src.fingerprint(t, "posta")
|
||||
dst.mailer(t, "mail")
|
||||
setEnv(t, src, "root", rootPW)
|
||||
t.Setenv("TARGET_DB_HOST", dst.name)
|
||||
t.Setenv("TARGET_DB_USERNAME", "mailer")
|
||||
t.Setenv("TARGET_DB_PASSWORD", appPW)
|
||||
t.Setenv("TARGET_DB_NAME", "mail")
|
||||
mustRun(t, "migrate", "-d", "posta")
|
||||
if got := dst.fingerprint(t, "mail"); got != want {
|
||||
t.Fatalf("migrated differs:\n got %s\nwant %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestForeignDump: a dump written elsewhere (mysqldump --databases: CREATE
|
||||
// DATABASE, USE, definers of the source server) uploaded into a database of
|
||||
// another name, by a user with privileges on that database only (Miabi).
|
||||
func TestForeignDump(t *testing.T) {
|
||||
forEachServer(t, func(t *testing.T, s *dbServer) {
|
||||
s.loadFixture(t, "posta")
|
||||
want := s.fingerprint(t, "posta")
|
||||
dump := func(dbs ...string) []byte { return s.dumpDatabases(t, dbs...) }
|
||||
// With binary logging on (MySQL's default), only SUPER may create
|
||||
// functions and triggers unless log_bin_trust_function_creators is set:
|
||||
// a server setting, not something a restore can change.
|
||||
s.mustSQL(t, fmt.Sprintf(`DROP USER IF EXISTS 'mailer'@'%%'; CREATE USER 'mailer'@'%%' IDENTIFIED BY '%s';
|
||||
DROP DATABASE IF EXISTS mail; CREATE DATABASE mail; GRANT ALL ON mail.* TO 'mailer'@'%%';
|
||||
DROP DATABASE IF EXISTS mail_root; SET GLOBAL log_bin_trust_function_creators = 1;`,
|
||||
strings.NewReplacer(`\`, `\\`, `'`, `\'`).Replace(appPW)))
|
||||
store := setEnv(t, s, "mailer", appPW)
|
||||
if err := os.WriteFile(filepath.Join(store, "posta.sql"), dump("posta"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := mustRun(t, "restore", "-d", "mail", "-f", "posta.sql")
|
||||
if got := s.fingerprint(t, "mail"); got != want {
|
||||
t.Fatalf("restored differs:\n got %s\nwant %s\n%s", got, want, out)
|
||||
}
|
||||
if got := s.mustSQL(t, "SELECT DISTINCT DEFINER FROM information_schema.VIEWS WHERE TABLE_SCHEMA = 'mail'"); got != "mailer@%" {
|
||||
t.Fatalf("view definer %q", got)
|
||||
}
|
||||
if got := s.fingerprint(t, "posta"); got != want {
|
||||
t.Fatal("the source database was touched")
|
||||
}
|
||||
|
||||
// root may keep the source definers (auto).
|
||||
setEnv(t, s, "root", rootPW)
|
||||
t.Setenv("STORAGE_PATH", store)
|
||||
mustRun(t, "restore", "-d", "mail_root", "-f", "posta.sql")
|
||||
if got := s.mustSQL(t, "SELECT DISTINCT DEFINER FROM information_schema.VIEWS WHERE TABLE_SCHEMA = 'mail_root'"); !strings.HasPrefix(got, "root@") {
|
||||
t.Fatalf("definer not kept: %q", got)
|
||||
}
|
||||
|
||||
// Two databases cannot go into one.
|
||||
s.loadFixture(t, "other")
|
||||
if err := os.WriteFile(filepath.Join(store, "two.sql"), dump("posta", "other"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out, code := run(t, "restore", "-d", "mail_two", "-f", "two.sql"); code == 0 || !strings.Contains(out, "more than one database") {
|
||||
t.Fatalf("restore of two databases: exit %d\n%s", code, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestMiabiS3: Miabi's exact S3 argv and env against MinIO.
|
||||
func TestMiabiS3(t *testing.T) {
|
||||
forEachServer(t, func(t *testing.T, s *dbServer) {
|
||||
s.loadFixture(t, "shop")
|
||||
want := s.fingerprint(t, "shop")
|
||||
setEnv(t, s, "root", rootPW)
|
||||
t.Setenv("AWS_S3_ENDPOINT", "http://127.0.0.1:"+minioPort)
|
||||
t.Setenv("AWS_S3_BUCKET_NAME", "backups")
|
||||
t.Setenv("AWS_REGION", "us-east-1")
|
||||
t.Setenv("AWS_ACCESS_KEY", "minioadmin")
|
||||
t.Setenv("AWS_SECRET_KEY", "minioadmin")
|
||||
t.Setenv("AWS_DISABLE_SSL", "true")
|
||||
t.Setenv("AWS_FORCE_PATH_STYLE", "true")
|
||||
t.Setenv("GPG_PASSPHRASE", "miabi")
|
||||
path := "miabi/" + strings.SplitN(s.name, "-", 3)[1]
|
||||
|
||||
var out string
|
||||
for i := 0; i < 2; i++ { // two runs: older names are printed too
|
||||
out = mustRun(t, "backup", "--storage", "s3", "-d", "shop", "--path", path)
|
||||
}
|
||||
file := artifact(t, out, "")
|
||||
if !strings.HasSuffix(file, ".sql.gz.gpg") {
|
||||
t.Fatalf("artifact %q", file)
|
||||
}
|
||||
if o, err := docker("exec", minioContainer, "mc", "stat", "l/backups/"+path+"/"+file); err != nil {
|
||||
t.Fatalf("object %s/%s not in the bucket: %s", path, file, o)
|
||||
}
|
||||
s.mustSQL(t, "DROP DATABASE shop")
|
||||
mustRun(t, "restore", "--storage", "s3", "-d", "shop", "--path", path, "-f", file)
|
||||
if got := s.fingerprint(t, "shop"); got != want {
|
||||
t.Fatalf("restored data differs")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestMigrate: dump | restore between two servers, single database and -a.
|
||||
func TestMigrate(t *testing.T) {
|
||||
needDocker(t)
|
||||
src := servers[0] // MySQL 8.4
|
||||
dst := servers[len(servers)-1]
|
||||
if dst.mariadb || dst == src {
|
||||
t.Skip("needs a second MySQL server (IT_SKIP_MYSQL80 is set)")
|
||||
}
|
||||
src.loadFixture(t, "shop")
|
||||
src.loadFixture(t, "blog")
|
||||
want := src.fingerprint(t, "shop")
|
||||
setEnv(t, src, "root", rootPW)
|
||||
t.Setenv("TARGET_DB_HOST", dst.name)
|
||||
t.Setenv("TARGET_DB_USERNAME", "root")
|
||||
t.Setenv("TARGET_DB_PASSWORD", rootPW)
|
||||
|
||||
dst.mustSQL(t, "DROP DATABASE IF EXISTS shop_copy")
|
||||
t.Setenv("TARGET_DB_NAME", "shop_copy")
|
||||
mustRun(t, "migrate", "-d", "shop")
|
||||
if got := dst.fingerprint(t, "shop_copy"); got != want {
|
||||
t.Fatalf("migrated data differs:\n got %s\nwant %s", got, want)
|
||||
}
|
||||
|
||||
t.Setenv("TARGET_DB_NAME", "")
|
||||
dst.mustSQL(t, "DROP DATABASE IF EXISTS shop; DROP DATABASE IF EXISTS blog")
|
||||
src.mustSQL(t, "DROP DATABASE IF EXISTS shop_gpg; DROP DATABASE IF EXISTS shop_age; DROP DATABASE IF EXISTS shop_up; DROP DATABASE IF EXISTS appdb; DROP DATABASE IF EXISTS skipme; DROP DATABASE IF EXISTS shop_bad")
|
||||
mustRun(t, "migrate", "-a")
|
||||
if got := dst.fingerprint(t, "blog"); got != src.fingerprint(t, "blog") {
|
||||
t.Fatalf("blog differs")
|
||||
}
|
||||
|
||||
// A failing target aborts the migration with a non-zero exit.
|
||||
t.Setenv("TARGET_DB_PASSWORD", "wrong")
|
||||
if out, code := run(t, "migrate", "-d", "shop"); code == 0 {
|
||||
t.Fatalf("migrate with bad target credentials succeeded:\n%s", out)
|
||||
}
|
||||
assertTmpClean(t)
|
||||
}
|
||||
|
||||
// TestMigrateMySQLToMariaDB: cross-flavour migrate (Oracle mysqldump | mariadb).
|
||||
func TestMigrateMySQLToMariaDB(t *testing.T) {
|
||||
needDocker(t)
|
||||
var src, dst *dbServer
|
||||
for _, s := range servers {
|
||||
if s.mariadb {
|
||||
dst = s
|
||||
} else if src == nil {
|
||||
src = s
|
||||
}
|
||||
}
|
||||
src.mustSQL(t, "DROP DATABASE IF EXISTS plain; CREATE DATABASE plain; CREATE TABLE plain.t (id INT PRIMARY KEY, v VARCHAR(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci); INSERT INTO plain.t VALUES (1,'x'),(2,'y');")
|
||||
setEnv(t, src, "root", rootPW)
|
||||
t.Setenv("TARGET_DB_HOST", dst.name)
|
||||
t.Setenv("TARGET_DB_USERNAME", "root")
|
||||
t.Setenv("TARGET_DB_PASSWORD", rootPW)
|
||||
t.Setenv("TARGET_DB_NAME", "plain")
|
||||
dst.mustSQL(t, "DROP DATABASE IF EXISTS plain")
|
||||
mustRun(t, "migrate", "-d", "plain")
|
||||
if got := dst.mustSQL(t, "SELECT COUNT(*) FROM plain.t"); got != "2" {
|
||||
t.Fatalf("rows: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/jkaninda/bkupkit/config"
|
||||
"github.com/jkaninda/bkupkit/execx"
|
||||
)
|
||||
|
||||
// Client binaries. Debian links mysql/mysqldump to the MariaDB tools, so the
|
||||
// Oracle ones are recognized by their --version output, not by name.
|
||||
const (
|
||||
mariadbCLI = "mariadb"
|
||||
mariadbDump = "mariadb-dump"
|
||||
mysqlCLI = "mysql"
|
||||
mysqlDump = "mysqldump"
|
||||
)
|
||||
|
||||
// Client preferences (MYSQL_CLIENT).
|
||||
const (
|
||||
ClientAuto = "auto"
|
||||
ClientMySQL = "mysql"
|
||||
ClientMariaDB = "mariadb"
|
||||
)
|
||||
|
||||
// tool is a resolved client binary.
|
||||
type tool struct {
|
||||
name string // logical name (mysqldump, mariadb-dump, ...)
|
||||
path string
|
||||
mariadb bool // MariaDB client (option names differ)
|
||||
}
|
||||
|
||||
// server is what Ping learned about a server.
|
||||
type server struct {
|
||||
version string
|
||||
mariadb bool
|
||||
}
|
||||
|
||||
// IsMariaDB reports whether a SELECT VERSION() result is a MariaDB server.
|
||||
func IsMariaDB(version string) bool {
|
||||
return strings.Contains(strings.ToLower(version), "mariadb")
|
||||
}
|
||||
|
||||
// lookTool resolves a binary and its client family (cached).
|
||||
func (e *Engine) lookTool(ctx context.Context, name string) (tool, bool) {
|
||||
e.mu.Lock()
|
||||
t, ok := e.tools[name]
|
||||
e.mu.Unlock()
|
||||
if ok {
|
||||
return t, t.path != ""
|
||||
}
|
||||
t = tool{name: name}
|
||||
if p, err := e.lookPath(name); err == nil {
|
||||
t.path = p
|
||||
switch name {
|
||||
case mariadbCLI, mariadbDump:
|
||||
t.mariadb = true
|
||||
default:
|
||||
out, err := execx.Output(ctx, p, []string{"--version"}, nil)
|
||||
if err != nil {
|
||||
t.path = ""
|
||||
} else {
|
||||
t.mariadb = IsMariaDB(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
e.mu.Lock()
|
||||
e.tools[name] = t
|
||||
e.mu.Unlock()
|
||||
return t, t.path != ""
|
||||
}
|
||||
|
||||
func (e *Engine) lookPath(name string) (string, error) {
|
||||
if e.LookPath != nil {
|
||||
return e.LookPath(name)
|
||||
}
|
||||
return exec.LookPath(name)
|
||||
}
|
||||
|
||||
// oracle returns the Oracle MySQL binary called name, if installed.
|
||||
func (e *Engine) oracle(ctx context.Context, name string) (tool, bool) {
|
||||
t, ok := e.lookTool(ctx, name)
|
||||
return t, ok && !t.mariadb
|
||||
}
|
||||
|
||||
// maria returns the MariaDB binary (mariadb-dump, or a mysqldump that is MariaDB's).
|
||||
func (e *Engine) maria(ctx context.Context, name, legacy string) (tool, bool) {
|
||||
if t, ok := e.lookTool(ctx, name); ok {
|
||||
return t, true
|
||||
}
|
||||
t, ok := e.lookTool(ctx, legacy)
|
||||
return t, ok && t.mariadb
|
||||
}
|
||||
|
||||
// pick chooses the dump (dump=true) or command-line client for a server:
|
||||
// mariadb-dump/mariadb for MariaDB, Oracle mysqldump/mysql for MySQL.
|
||||
func (e *Engine) pick(ctx context.Context, pref string, srv server, dump bool) (tool, error) {
|
||||
oName, mName := mysqlCLI, mariadbCLI
|
||||
if dump {
|
||||
oName, mName = mysqlDump, mariadbDump
|
||||
}
|
||||
switch pref {
|
||||
case ClientMySQL:
|
||||
if t, ok := e.oracle(ctx, oName); ok {
|
||||
return t, nil
|
||||
}
|
||||
return tool{}, fmt.Errorf("MYSQL_CLIENT=mysql but the Oracle MySQL %s client is not installed", oName)
|
||||
case ClientMariaDB:
|
||||
if t, ok := e.maria(ctx, mName, oName); ok {
|
||||
return t, nil
|
||||
}
|
||||
return tool{}, fmt.Errorf("MYSQL_CLIENT=mariadb but %s is not installed", mName)
|
||||
}
|
||||
if srv.mariadb {
|
||||
if t, ok := e.maria(ctx, mName, oName); ok {
|
||||
return t, nil
|
||||
}
|
||||
if t, ok := e.oracle(ctx, oName); ok {
|
||||
e.warnOnce("oracle-for-mariadb", "MariaDB client not installed; using the Oracle MySQL client against a MariaDB server", "client", t.name)
|
||||
return t, nil
|
||||
}
|
||||
return tool{}, fmt.Errorf("no %s or %s client installed", mName, oName)
|
||||
}
|
||||
if t, ok := e.oracle(ctx, oName); ok {
|
||||
return t, nil
|
||||
}
|
||||
if t, ok := e.maria(ctx, mName, oName); ok {
|
||||
e.warnOnce("mariadb-for-mysql",
|
||||
"Oracle MySQL client not available on this platform; using the MariaDB client against a MySQL server "+
|
||||
"(dumps of MySQL-only features may be incomplete)",
|
||||
"client", t.name, "server_version", srv.version)
|
||||
return t, nil
|
||||
}
|
||||
return tool{}, fmt.Errorf("no %s or %s client installed", oName, mName)
|
||||
}
|
||||
|
||||
func (e *Engine) warnOnce(key, msg string, args ...any) {
|
||||
e.mu.Lock()
|
||||
seen := e.warned[key]
|
||||
e.warned[key] = true
|
||||
e.mu.Unlock()
|
||||
if !seen {
|
||||
slog.Default().Warn(msg, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// withOptionFile writes c's option file (0600, private directory) and calls fn
|
||||
// with its path. The file is removed when fn returns.
|
||||
func withOptionFile(v *config.Values, c Conn, t tool, fn func(path string) error) error {
|
||||
dir, cleanup, err := execx.PrivateDir(v.String(config.TmpDir))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create private directory: %w", err)
|
||||
}
|
||||
defer cleanup()
|
||||
p, err := execx.WriteSecret(dir, "my.cnf", optionFile(c, t.mariadb))
|
||||
if err != nil {
|
||||
return fmt.Errorf("write client option file: %w", err)
|
||||
}
|
||||
return fn(p)
|
||||
}
|
||||
|
||||
// query runs one SQL statement with the command-line client and returns the
|
||||
// raw tab-separated output without headers.
|
||||
func query(ctx context.Context, v *config.Values, c Conn, t tool, sql string) (string, error) {
|
||||
var out string
|
||||
err := withOptionFile(v, c, t, func(cnf string) error {
|
||||
var err error
|
||||
out, err = execx.Output(ctx, t.path, []string{"--defaults-extra-file=" + cnf, "-N", "-B", "-r", "-e", sql}, nil)
|
||||
return err
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// server detects the server version and flavour (cached per address and user).
|
||||
// It tries the MariaDB client first (it speaks every MySQL authentication
|
||||
// method), then the Oracle client.
|
||||
func (e *Engine) server(ctx context.Context, v *config.Values, c Conn) (server, error) {
|
||||
key := c.Addr() + "\x00" + c.User
|
||||
e.mu.Lock()
|
||||
s, ok := e.servers[key]
|
||||
e.mu.Unlock()
|
||||
if ok {
|
||||
return s, nil
|
||||
}
|
||||
var cands []tool
|
||||
if t, ok := e.maria(ctx, mariadbCLI, mysqlCLI); ok {
|
||||
cands = append(cands, t)
|
||||
}
|
||||
if t, ok := e.oracle(ctx, mysqlCLI); ok {
|
||||
cands = append(cands, t)
|
||||
}
|
||||
if len(cands) == 0 {
|
||||
return s, errors.New("no mariadb or mysql client installed")
|
||||
}
|
||||
var firstErr error
|
||||
for _, t := range cands {
|
||||
out, err := query(ctx, v, c, t, "SELECT VERSION()")
|
||||
if err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
s = server{version: strings.TrimSpace(out), mariadb: IsMariaDB(out)}
|
||||
e.mu.Lock()
|
||||
e.servers[key] = s
|
||||
e.mu.Unlock()
|
||||
return s, nil
|
||||
}
|
||||
return s, fmt.Errorf("failed to connect to database server %s: %w", c.Addr(), firstErr)
|
||||
}
|
||||
|
||||
// systemDatabases are never backed up one by one (mysql-bkup 1.x list).
|
||||
var systemDatabases = map[string]bool{
|
||||
"information_schema": true, "performance_schema": true, "mysql": true, "sys": true, "innodb": true,
|
||||
}
|
||||
|
||||
// listDatabases returns the user databases, minus exclude.
|
||||
func (e *Engine) listDatabases(ctx context.Context, v *config.Values, c Conn, exclude []string) ([]string, error) {
|
||||
srv, err := e.server(ctx, v, c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t, err := e.pick(ctx, v.String(SettingClient), srv, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := query(ctx, v, c, t, "SHOW DATABASES")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list databases: %w", err)
|
||||
}
|
||||
return filterDatabases(strings.Split(out, "\n"), exclude), nil
|
||||
}
|
||||
|
||||
func filterDatabases(names, exclude []string) []string {
|
||||
skip := map[string]bool{}
|
||||
for _, x := range exclude {
|
||||
skip[x] = true
|
||||
}
|
||||
var dbs []string
|
||||
for _, n := range names {
|
||||
n = strings.TrimRight(n, "\r")
|
||||
if n == "" || systemDatabases[strings.ToLower(n)] || skip[n] {
|
||||
continue
|
||||
}
|
||||
dbs = append(dbs, n)
|
||||
}
|
||||
return dbs
|
||||
}
|
||||
|
||||
// quoteIdent quotes a MySQL identifier.
|
||||
func quoteIdent(s string) string {
|
||||
return "`" + strings.ReplaceAll(s, "`", "``") + "`"
|
||||
}
|
||||
|
||||
// ensureDatabase creates db when it does not exist yet. The existence check
|
||||
// runs first so that users without CREATE privilege can restore into an
|
||||
// existing database.
|
||||
func ensureDatabase(ctx context.Context, v *config.Values, c Conn, t tool, db string) error {
|
||||
lit := "CONVERT(X'" + hex.EncodeToString([]byte(db)) + "' USING utf8mb4)"
|
||||
out, err := query(ctx, v, c, t, "SELECT COUNT(*) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = "+lit)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check database %s: %w", db, err)
|
||||
}
|
||||
if strings.TrimSpace(out) != "0" {
|
||||
return nil
|
||||
}
|
||||
slog.Default().Info("Creating database", "database", db)
|
||||
if _, err := query(ctx, v, c, t, "CREATE DATABASE IF NOT EXISTS "+quoteIdent(db)); err != nil {
|
||||
return fmt.Errorf("create database %s: %w", db, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/jkaninda/bkupkit/config"
|
||||
)
|
||||
|
||||
// TLS modes accepted in DB_SSL_MODE (MySQL --ssl-mode names, lower-case).
|
||||
const (
|
||||
SSLDisabled = "disabled"
|
||||
SSLPreferred = "preferred"
|
||||
SSLRequired = "required"
|
||||
SSLVerifyCA = "verify_ca"
|
||||
SSLVerifyIdentity = "verify_identity"
|
||||
)
|
||||
|
||||
// ParseSSLMode normalizes DB_SSL_MODE. mysql-bkup 1.x wrote the value into
|
||||
// my.cnf as "ssl=<value>", so "0"/"1" (and booleans) are still accepted.
|
||||
// An empty value means disabled, the 1.x default.
|
||||
func ParseSSLMode(s string) (string, error) {
|
||||
m := strings.ToLower(strings.TrimSpace(s))
|
||||
m = strings.ReplaceAll(m, "-", "_")
|
||||
switch m {
|
||||
case "", "0", "false", "off", "no", SSLDisabled, "disable":
|
||||
return SSLDisabled, nil
|
||||
case "1", "true", "on", "yes", SSLRequired, "require":
|
||||
return SSLRequired, nil
|
||||
case SSLPreferred, "prefer":
|
||||
return SSLPreferred, nil
|
||||
case SSLVerifyCA:
|
||||
return SSLVerifyCA, nil
|
||||
case SSLVerifyIdentity, "verify_full":
|
||||
return SSLVerifyIdentity, nil
|
||||
}
|
||||
return "", fmt.Errorf("invalid TLS mode %q: use disabled, preferred, required, verify_ca or verify_identity", s)
|
||||
}
|
||||
|
||||
// Conn is a resolved connection.
|
||||
type Conn struct {
|
||||
Host string
|
||||
Port int
|
||||
User string
|
||||
Password string
|
||||
DB string
|
||||
SSLMode string
|
||||
SSLCA string
|
||||
SSLCert string
|
||||
SSLKey string
|
||||
}
|
||||
|
||||
// Addr is host:port (or the socket path), for logs and cache keys.
|
||||
func (c Conn) Addr() string {
|
||||
if strings.HasPrefix(c.Host, "/") {
|
||||
return c.Host
|
||||
}
|
||||
return c.Host + ":" + strconv.Itoa(c.Port)
|
||||
}
|
||||
|
||||
// ParseURL parses mysql://user:pass@host:port/db?ssl-mode=required (also
|
||||
// mariadb://). Query parameters: ssl-mode (sslmode, tls), ssl-ca, ssl-cert, ssl-key.
|
||||
func ParseURL(raw string) (Conn, error) {
|
||||
var c Conn
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return c, errors.New("invalid database URL")
|
||||
}
|
||||
switch strings.ToLower(u.Scheme) {
|
||||
case "mysql", "mariadb", "mysqlx":
|
||||
default:
|
||||
return c, fmt.Errorf("unsupported database URL scheme %q (use mysql://user:password@host:port/database)", u.Scheme)
|
||||
}
|
||||
c.Host = u.Hostname()
|
||||
if p := u.Port(); p != "" {
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil || n <= 0 || n > 65535 {
|
||||
return c, fmt.Errorf("invalid port %q in database URL", p)
|
||||
}
|
||||
c.Port = n
|
||||
}
|
||||
if u.User != nil {
|
||||
c.User = u.User.Username()
|
||||
c.Password, _ = u.User.Password()
|
||||
}
|
||||
c.DB = strings.TrimPrefix(u.Path, "/")
|
||||
q := u.Query()
|
||||
for _, k := range []string{"ssl-mode", "sslmode", "ssl_mode", "tls"} {
|
||||
if v := q.Get(k); v != "" {
|
||||
if c.SSLMode, err = ParseSSLMode(v); err != nil {
|
||||
return c, err
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
c.SSLCA = first(q.Get("ssl-ca"), q.Get("sslrootcert"))
|
||||
c.SSLCert = first(q.Get("ssl-cert"), q.Get("sslcert"))
|
||||
c.SSLKey = first(q.Get("ssl-key"), q.Get("sslkey"))
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func first(s ...string) string {
|
||||
for _, v := range s {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// connKeys names the settings of one side (source or migrate target).
|
||||
type connKeys struct {
|
||||
URL, Host, Port, Name, User, Pass, SSLMode, SSLCA, SSLCert, SSLKey string
|
||||
}
|
||||
|
||||
var (
|
||||
sourceKeys = connKeys{config.DBURL, config.DBHost, config.DBPort, config.DBName, config.DBUser, config.DBPassword,
|
||||
config.DBSSLMode, config.DBSSLCA, config.DBSSLCert, config.DBSSLKey}
|
||||
targetKeys = connKeys{config.TargetDBURL, config.TargetDBHost, config.TargetDBPort, config.TargetDBName, config.TargetDBUser,
|
||||
config.TargetDBPass, config.TargetDBSSL, config.TargetDBSSLCA, "", ""}
|
||||
)
|
||||
|
||||
// connFrom resolves a connection: DB_URL first, then the individual settings
|
||||
// (which also fill in what the URL leaves out).
|
||||
func connFrom(v *config.Values, k connKeys) (Conn, error) {
|
||||
var c Conn
|
||||
if raw := v.String(k.URL); raw != "" {
|
||||
var err error
|
||||
if c, err = ParseURL(raw); err != nil {
|
||||
return c, fmt.Errorf("%s: %w", k.URL, err)
|
||||
}
|
||||
}
|
||||
str := func(dst *string, key string) {
|
||||
if *dst == "" && key != "" {
|
||||
*dst = v.String(key)
|
||||
}
|
||||
}
|
||||
str(&c.Host, k.Host)
|
||||
str(&c.User, k.User)
|
||||
str(&c.Password, k.Pass)
|
||||
str(&c.DB, k.Name)
|
||||
str(&c.SSLCA, k.SSLCA)
|
||||
str(&c.SSLCert, k.SSLCert)
|
||||
str(&c.SSLKey, k.SSLKey)
|
||||
if c.Port == 0 {
|
||||
c.Port = v.Int(k.Port)
|
||||
}
|
||||
if c.Port == 0 {
|
||||
c.Port = 3306
|
||||
}
|
||||
if c.SSLMode == "" {
|
||||
m, err := ParseSSLMode(v.String(k.SSLMode))
|
||||
if err != nil {
|
||||
return c, fmt.Errorf("%s: %w", k.SSLMode, err)
|
||||
}
|
||||
c.SSLMode = m
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// SourceConn returns the DB_* connection of v.
|
||||
func SourceConn(v *config.Values) (Conn, error) { return connFrom(v, sourceKeys) }
|
||||
|
||||
// TargetConn returns the TARGET_DB_* connection of v (migrate).
|
||||
func TargetConn(v *config.Values) (Conn, error) {
|
||||
c, err := connFrom(v, targetKeys)
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
if v.String(config.TargetDBURL) == "" && v.String(config.TargetDBHost) == "" {
|
||||
return c, errors.New("missing required configuration: TARGET_DB_HOST (or TARGET_DB_URL)")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c Conn) validate(prefix string) error {
|
||||
var missing []string
|
||||
if c.Host == "" {
|
||||
missing = append(missing, prefix+"DB_HOST")
|
||||
}
|
||||
if c.User == "" {
|
||||
missing = append(missing, prefix+"DB_USER")
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("missing required configuration: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkDBName rejects names that a client would parse as an option.
|
||||
func checkDBName(name string) error {
|
||||
if strings.HasPrefix(name, "-") {
|
||||
return fmt.Errorf("invalid database name %q", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
// Package mysql is the mysql-bkup engine for bkupkit: MySQL and MariaDB
|
||||
// logical backups with mysqldump / mariadb-dump, restored with mysql / mariadb.
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/jkaninda/bkupkit/cli"
|
||||
"github.com/jkaninda/bkupkit/config"
|
||||
"github.com/jkaninda/bkupkit/engine"
|
||||
"github.com/jkaninda/bkupkit/execx"
|
||||
)
|
||||
|
||||
// Engine-specific settings.
|
||||
const (
|
||||
SettingClient = "MYSQL_CLIENT"
|
||||
// RestoreNoDefiner removes DEFINER clauses on restore: auto (when the
|
||||
// restoring user may not name another definer), true or false.
|
||||
RestoreNoDefiner = "RESTORE_NO_DEFINER"
|
||||
)
|
||||
|
||||
// AllInOneName is the target (and default artifact) name of --all-in-one backups.
|
||||
const AllInOneName = "all_databases"
|
||||
|
||||
// DefaultCA is the 1.x default of DB_SSL_CA.
|
||||
const DefaultCA = "/etc/ssl/certs/ca-certificates.crt"
|
||||
|
||||
// Engine implements engine.Engine and engine.Migrator.
|
||||
type Engine struct {
|
||||
// LookPath finds client binaries (default exec.LookPath); tests replace it.
|
||||
LookPath func(string) (string, error)
|
||||
|
||||
mu sync.Mutex
|
||||
tools map[string]tool
|
||||
servers map[string]server
|
||||
warned map[string]bool
|
||||
}
|
||||
|
||||
var (
|
||||
_ engine.Engine = (*Engine)(nil)
|
||||
_ engine.Migrator = (*Engine)(nil)
|
||||
)
|
||||
|
||||
// New returns the engine.
|
||||
func New() *Engine {
|
||||
return &Engine{tools: map[string]tool{}, servers: map[string]server{}, warned: map[string]bool{}}
|
||||
}
|
||||
|
||||
// Tool implements engine.Engine.
|
||||
func (e *Engine) Tool() string { return "mysql-bkup" }
|
||||
|
||||
// Kind implements engine.Engine. It is "mysql" for MySQL and MariaDB alike so
|
||||
// that {engine} in the index name does not depend on the server.
|
||||
func (e *Engine) Kind() string { return "mysql" }
|
||||
|
||||
// Settings implements engine.Engine.
|
||||
func (e *Engine) Settings(r *config.Registry) {
|
||||
config.RegisterDatabase(r, "3306")
|
||||
r.Override(config.DBURL, func(s *config.Setting) {
|
||||
s.Usage = "Connection URL mysql://user:password@host:port/database?ssl-mode=required (overrides host, port, user, password)"
|
||||
})
|
||||
r.Override(config.DBHost, func(s *config.Setting) {
|
||||
s.Usage = "Database host (a path starting with / is a Unix socket)"
|
||||
})
|
||||
r.Override(config.DBSSLMode, func(s *config.Setting) {
|
||||
s.Default = SSLDisabled
|
||||
s.Usage = "TLS mode: disabled, preferred, required, verify_ca, verify_identity (1.x values 0 and 1 mean disabled and required)"
|
||||
})
|
||||
r.Override(config.DBSSLCA, func(s *config.Setting) {
|
||||
s.Default = DefaultCA
|
||||
s.Usage = "CA certificate file, used with DB_SSL_MODE=verify_ca or verify_identity"
|
||||
})
|
||||
r.Override(config.TargetDBSSL, func(s *config.Setting) {
|
||||
s.Default = SSLDisabled
|
||||
s.Usage = "migrate: target TLS mode (same values as DB_SSL_MODE)"
|
||||
})
|
||||
r.Override(config.TargetDBSSLCA, func(s *config.Setting) {
|
||||
s.Default = DefaultCA
|
||||
})
|
||||
r.Override(config.TargetDBName, func(s *config.Setting) {
|
||||
s.Usage = "migrate: target database (default: the source name)"
|
||||
})
|
||||
// 1.x accepted --storage remote for SFTP.
|
||||
r.Override(config.Storage, func(s *config.Setting) {
|
||||
s.Enum = append(s.Enum, "remote")
|
||||
s.Usage = "Storage backend: local, s3, gcs, azure, sftp (ssh, remote), ftp"
|
||||
})
|
||||
r.Add(config.Setting{
|
||||
Key: SettingClient, Default: ClientAuto, Enum: []string{ClientAuto, ClientMySQL, ClientMariaDB},
|
||||
Group: config.GroupDatabase,
|
||||
Usage: "Client tools: auto (mariadb-dump for MariaDB, Oracle mysqldump for MySQL), mysql or mariadb",
|
||||
})
|
||||
r.Add(config.Setting{
|
||||
Key: RestoreNoDefiner, Default: "auto", Enum: []string{"auto", "true", "false"},
|
||||
Group: config.GroupDatabase,
|
||||
Usage: "Remove DEFINER clauses on restore (views, triggers, routines and events then belong " +
|
||||
"to the restoring user); auto = when that user lacks SET_USER_ID/SUPER",
|
||||
})
|
||||
}
|
||||
|
||||
// Configure implements engine.Engine. It only validates values: commands that
|
||||
// do not connect (list, prune, verify) must work without credentials.
|
||||
func (e *Engine) Configure(_ context.Context, v *config.Values) error {
|
||||
normalizeStorage(v)
|
||||
var errs []error
|
||||
if _, err := SourceConn(v); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
if v.Has(config.TargetDBURL) || v.Has(config.TargetDBHost) {
|
||||
if _, err := TargetConn(v); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// normalizeStorage maps the 1.x storage name "remote" to sftp, and an
|
||||
// absolute local FILE_NAME (1.x: restore -f /backup/x.sql.gz) to a file name
|
||||
// relative to STORAGE_PATH (or to its own directory, as 1.x did).
|
||||
func normalizeStorage(v *config.Values) {
|
||||
if v.String(config.Storage) == "remote" {
|
||||
v.Set(config.Storage, "sftp")
|
||||
}
|
||||
f := v.String(config.FileName)
|
||||
if v.String(config.Storage) != "local" || !filepath.IsAbs(f) || v.Has(config.RemotePath) {
|
||||
return
|
||||
}
|
||||
root := filepath.Clean(v.String(config.StoragePath))
|
||||
if rel, err := filepath.Rel(root, filepath.Clean(f)); err == nil && !strings.HasPrefix(rel, "..") {
|
||||
v.Set(config.FileName, filepath.ToSlash(rel))
|
||||
return
|
||||
}
|
||||
v.Set(config.StoragePath, filepath.Dir(f))
|
||||
v.Set(config.FileName, filepath.Base(f))
|
||||
}
|
||||
|
||||
// restoreContext reports whether v belongs to a restore/verify/delete run,
|
||||
// where one database at most is involved. Outside the CLI (no command), a
|
||||
// FILE_NAME means the same.
|
||||
func restoreContext(v *config.Values) bool {
|
||||
switch v.Command {
|
||||
case "restore", "verify", "delete":
|
||||
return true
|
||||
case "":
|
||||
return v.Has(config.FileName) && !v.Bool(config.AllDatabases) && !v.Bool(config.AllInOne)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Targets implements engine.Engine.
|
||||
func (e *Engine) Targets(ctx context.Context, v *config.Values) ([]engine.Target, error) {
|
||||
normalizeStorage(v) // per-job values of a config file are resolved again
|
||||
c, err := SourceConn(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if restoreContext(v) {
|
||||
// restore -f FILE [-d DB]: without a database, the dump's own USE
|
||||
// statements decide (--all-in-one dumps).
|
||||
if err := checkDBName(c.DB); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []engine.Target{{Name: c.DB, Values: v}}, nil
|
||||
}
|
||||
switch {
|
||||
case v.Bool(config.AllInOne):
|
||||
return []engine.Target{{Name: AllInOneName, Values: v}}, nil
|
||||
case v.Bool(config.AllDatabases):
|
||||
if err := c.validate(""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dbs, err := e.listDatabases(ctx, v, c, v.List(config.ExcludeDBs))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(dbs) == 0 {
|
||||
return nil, errors.New("no databases found")
|
||||
}
|
||||
ts := make([]engine.Target, 0, len(dbs))
|
||||
for _, db := range dbs {
|
||||
ts = append(ts, engine.Target{Name: db, Values: v})
|
||||
}
|
||||
return ts, nil
|
||||
}
|
||||
// No database: fine for list, prune, verify and delete; Ping rejects it
|
||||
// for a backup.
|
||||
if err := checkDBName(c.DB); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []engine.Target{{Name: c.DB, Values: v}}, nil
|
||||
}
|
||||
|
||||
// errNoDatabase is a configuration error (exit code 2).
|
||||
var errNoDatabase = &cli.ExitError{Code: cli.ExitConfig, Err: errors.New("database name is required, use DB_NAME environment variable or -d flag")}
|
||||
|
||||
// Ext implements engine.Engine.
|
||||
func (e *Engine) Ext(engine.Target) string { return ".sql" }
|
||||
|
||||
// NativeCompression implements engine.Engine.
|
||||
func (e *Engine) NativeCompression(engine.Target) bool { return false }
|
||||
|
||||
// VerifyBeforeRestore makes RESTORE_VERIFY_FIRST=auto read and check the whole
|
||||
// dump before the client runs: MySQL cannot roll back the dump's DROP/CREATE
|
||||
// TABLE statements, so a backup that turns out to be damaged halfway would
|
||||
// otherwise leave the database half-restored.
|
||||
func (e *Engine) VerifyBeforeRestore(engine.Target, engine.ArtifactInfo) bool { return true }
|
||||
|
||||
var _ engine.RestoreVerifier = (*Engine)(nil)
|
||||
|
||||
// Ping implements engine.Engine: it connects, and detects the server flavour.
|
||||
func (e *Engine) Ping(ctx context.Context, t engine.Target) error {
|
||||
c, err := SourceConn(t.Values)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if t.Name == "" && !restoreContext(t.Values) {
|
||||
return errNoDatabase
|
||||
}
|
||||
if err := c.validate(""); err != nil {
|
||||
return &cli.ExitError{Code: cli.ExitConfig, Err: err}
|
||||
}
|
||||
srv, err := e.server(ctx, t.Values, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
flavour := "MySQL"
|
||||
if srv.mariadb {
|
||||
flavour = "MariaDB"
|
||||
}
|
||||
slog.Default().Info("Connected to database server", "server", c.Addr(), "flavour", flavour, "version", srv.version)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ServerVersion implements engine.Engine.
|
||||
func (e *Engine) ServerVersion(ctx context.Context, t engine.Target) string {
|
||||
c, err := SourceConn(t.Values)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
srv, err := e.server(ctx, t.Values, c)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return srv.version
|
||||
}
|
||||
|
||||
// dumpSpec describes what a dump covers.
|
||||
type dumpSpec struct {
|
||||
db string // one database (no CREATE DATABASE / USE: restorable under another name)
|
||||
all bool // --all-databases
|
||||
databases []string // --databases list (all-in-one with exclusions)
|
||||
}
|
||||
|
||||
// DumpArgs returns the dump arguments after --defaults-extra-file.
|
||||
// mariadbTool is the client family, mariadbServer the server flavour.
|
||||
func DumpArgs(mariadbTool, mariadbServer bool, s dumpSpec) []string {
|
||||
args := []string{"--single-transaction", "--routines", "--triggers", "--events", "--hex-blob", "--quick"}
|
||||
if !mariadbServer {
|
||||
// MySQL: no tablespace statements (they need the PROCESS privilege
|
||||
// and are useless in logical backups).
|
||||
args = append(args, "--no-tablespaces")
|
||||
if !mariadbTool {
|
||||
args = append(args, "--set-gtid-purged=OFF")
|
||||
}
|
||||
} else if !mariadbTool {
|
||||
// Oracle mysqldump against MariaDB: no column_statistics table there.
|
||||
args = append(args, "--column-statistics=0")
|
||||
}
|
||||
switch {
|
||||
case s.all:
|
||||
args = append(args, "--all-databases")
|
||||
case len(s.databases) > 0:
|
||||
args = append(args, "--databases")
|
||||
args = append(args, s.databases...)
|
||||
default:
|
||||
args = append(args, s.db)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func (e *Engine) dumpCmd(ctx context.Context, t engine.Target, c Conn) (tool, []string, error) {
|
||||
v := t.Values
|
||||
srv, err := e.server(ctx, v, c)
|
||||
if err != nil {
|
||||
return tool{}, nil, err
|
||||
}
|
||||
tl, err := e.pick(ctx, v.String(SettingClient), srv, true)
|
||||
if err != nil {
|
||||
return tool{}, nil, err
|
||||
}
|
||||
spec := dumpSpec{db: t.Name}
|
||||
if t.Name == AllInOneName && v.Bool(config.AllInOne) {
|
||||
spec = dumpSpec{all: true}
|
||||
if ex := v.List(config.ExcludeDBs); len(ex) > 0 {
|
||||
dbs, err := e.listDatabases(ctx, v, c, ex)
|
||||
if err != nil {
|
||||
return tool{}, nil, err
|
||||
}
|
||||
if len(dbs) == 0 {
|
||||
return tool{}, nil, errors.New("no databases left after BACKUP_EXCLUDE_DATABASES")
|
||||
}
|
||||
spec = dumpSpec{databases: dbs}
|
||||
}
|
||||
} else if err := checkDBName(t.Name); err != nil || t.Name == "" {
|
||||
return tool{}, nil, fmt.Errorf("invalid database name %q", t.Name)
|
||||
}
|
||||
return tl, DumpArgs(tl.mariadb, srv.mariadb, spec), nil
|
||||
}
|
||||
|
||||
// Dump implements engine.Engine: the dump goes straight to w.
|
||||
func (e *Engine) Dump(ctx context.Context, t engine.Target, w io.Writer) error {
|
||||
c, err := SourceConn(t.Values)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tl, args, err := e.dumpCmd(ctx, t, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Default().Info("Dumping database", "database", t.Name, "client", tl.name)
|
||||
return withOptionFile(t.Values, c, tl, func(cnf string) error {
|
||||
return execx.Run(ctx, execx.Cmd{
|
||||
Name: tl.path, Args: append([]string{"--defaults-extra-file=" + cnf}, args...),
|
||||
Stdout: w, Log: slog.Default(), StderrLevel: slog.LevelWarn,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// restoreDB returns the database a dump is replayed into ("" = none: the
|
||||
// dump's USE statements decide).
|
||||
func restoreDB(t engine.Target) string {
|
||||
if t.Name == AllInOneName && t.Values.Bool(config.AllInOne) {
|
||||
return ""
|
||||
}
|
||||
return t.Name
|
||||
}
|
||||
|
||||
func (e *Engine) restoreCmd(ctx context.Context, v *config.Values, c Conn, db string) (tool, []string, error) {
|
||||
if err := checkDBName(db); err != nil {
|
||||
return tool{}, nil, err
|
||||
}
|
||||
srv, err := e.server(ctx, v, c)
|
||||
if err != nil {
|
||||
return tool{}, nil, err
|
||||
}
|
||||
tl, err := e.pick(ctx, v.String(SettingClient), srv, false)
|
||||
if err != nil {
|
||||
return tool{}, nil, err
|
||||
}
|
||||
if db != "" {
|
||||
if err := ensureDatabase(ctx, v, c, tl, db); err != nil {
|
||||
return tool{}, nil, err
|
||||
}
|
||||
}
|
||||
// --binary-mode: dumps may contain \0 and \r in string literals.
|
||||
args := []string{"--binary-mode"}
|
||||
if db != "" {
|
||||
args = append(args, db)
|
||||
}
|
||||
return tl, args, nil
|
||||
}
|
||||
|
||||
// Restore implements engine.Engine. The client reads the dump from stdin and
|
||||
// stops at the first failing statement (non-zero exit). If r fails, execx
|
||||
// kills the client before it sees EOF. MySQL cannot roll back DDL, so a
|
||||
// failed restore can leave the statements before the failure applied.
|
||||
func (e *Engine) Restore(ctx context.Context, t engine.Target, r io.Reader, info engine.ArtifactInfo) error {
|
||||
c, err := SourceConn(t.Values)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
db := restoreDB(t)
|
||||
tl, args, err := e.restoreCmd(ctx, t.Values, c, db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Default().Info("Restoring database", "database", db, "file", info.File, "client", tl.name)
|
||||
in := RestoreFilter(r, e.filterOptions(ctx, t.Values, c, tl, db))
|
||||
defer func() { _ = in.Close() }()
|
||||
return withOptionFile(t.Values, c, tl, func(cnf string) error {
|
||||
return execx.Run(ctx, execx.Cmd{
|
||||
Name: tl.path, Args: append([]string{"--defaults-extra-file=" + cnf}, args...),
|
||||
Stdin: in, Log: slog.Default(), StderrLevel: slog.LevelWarn,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// filterOptions describes how a dump restored into db through tl is adapted.
|
||||
func (e *Engine) filterOptions(ctx context.Context, v *config.Values, c Conn, tl tool, db string) FilterOptions {
|
||||
o := FilterOptions{Target: db, NoDefiner: e.noDefiner(ctx, v, c, tl), OracleClient: !tl.mariadb}
|
||||
if srv, err := e.server(ctx, v, c); err == nil {
|
||||
o.MySQLServer = !srv.mariadb
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// noDefiner resolves RESTORE_NO_DEFINER for the restoring user.
|
||||
func (e *Engine) noDefiner(ctx context.Context, v *config.Values, c Conn, tl tool) bool {
|
||||
switch v.String(RestoreNoDefiner) {
|
||||
case "true":
|
||||
return true
|
||||
case "false":
|
||||
return false
|
||||
}
|
||||
grants, err := query(ctx, v, c, tl, "SHOW GRANTS")
|
||||
if err != nil {
|
||||
slog.Default().Warn("Could not read the restoring user's grants; removing DEFINER clauses", "error", err)
|
||||
return true
|
||||
}
|
||||
return !canSetDefiner(grants)
|
||||
}
|
||||
|
||||
// canSetDefiner reports whether SHOW GRANTS output allows naming another
|
||||
// definer: SUPER, SET_USER_ID / SET_ANY_DEFINER (MySQL 8) or SET USER (MariaDB).
|
||||
func canSetDefiner(grants string) bool {
|
||||
for _, line := range strings.Split(strings.ToUpper(grants), "\n") {
|
||||
privs, _, global := strings.Cut(line, " ON *.* ")
|
||||
if !global {
|
||||
continue
|
||||
}
|
||||
for _, p := range []string{"ALL PRIVILEGES", "SUPER", "SET_USER_ID", "SET_ANY_DEFINER", "SET USER"} {
|
||||
if strings.Contains(privs, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MigrateTargets implements engine.Migrator: DB_NAME → TARGET_DB_NAME (default
|
||||
// the same name), or every database with --all-databases.
|
||||
func (e *Engine) MigrateTargets(ctx context.Context, v *config.Values) ([][2]engine.Target, error) {
|
||||
tc, err := TargetConn(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tc.validate("TARGET_"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sc, err := SourceConn(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := sc.validate(""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if v.Bool(config.AllDatabases) || v.Bool(config.AllInOne) {
|
||||
dbs, err := e.listDatabases(ctx, v, sc, v.List(config.ExcludeDBs))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pairs := make([][2]engine.Target, 0, len(dbs))
|
||||
for _, db := range dbs {
|
||||
pairs = append(pairs, [2]engine.Target{{Name: db, Values: v}, {Name: db, Values: v}})
|
||||
}
|
||||
return pairs, nil
|
||||
}
|
||||
if sc.DB == "" {
|
||||
return nil, errNoDatabase
|
||||
}
|
||||
name := tc.DB
|
||||
if name == "" {
|
||||
name = sc.DB
|
||||
}
|
||||
for _, n := range []string{sc.DB, name} {
|
||||
if err := checkDBName(n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return [][2]engine.Target{{{Name: sc.DB, Values: v}, {Name: name, Values: v}}}, nil
|
||||
}
|
||||
|
||||
// Migrate implements engine.Migrator: dump | restore through a pipe, no file.
|
||||
func (e *Engine) Migrate(ctx context.Context, source, target engine.Target) error {
|
||||
sc, err := SourceConn(source.Values)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tc, err := TargetConn(target.Values)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dumpTool, dumpArgs, err := e.dumpCmd(ctx, source, sc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("source: %w", err)
|
||||
}
|
||||
restTool, restArgs, err := e.restoreCmd(ctx, target.Values, tc, target.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("target: %w", err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
// The first failure is the cause; the other side then fails because of it.
|
||||
var (
|
||||
once sync.Once
|
||||
cause error
|
||||
)
|
||||
fail := func(err error) {
|
||||
once.Do(func() { cause = err })
|
||||
cancel()
|
||||
}
|
||||
pr, pw := io.Pipe()
|
||||
in := RestoreFilter(pr, e.filterOptions(ctx, target.Values, tc, restTool, target.Name))
|
||||
defer func() { _ = in.Close() }()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
err := withOptionFile(source.Values, sc, dumpTool, func(cnf string) error {
|
||||
return execx.Run(ctx, execx.Cmd{
|
||||
Name: dumpTool.path, Args: append([]string{"--defaults-extra-file=" + cnf}, dumpArgs...),
|
||||
Stdout: pw, Log: slog.Default(), StderrLevel: slog.LevelWarn,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
err = fmt.Errorf("dump %s: %w", source.Name, err)
|
||||
fail(err)
|
||||
// The restore side must never see a clean EOF.
|
||||
_ = pw.CloseWithError(err)
|
||||
return
|
||||
}
|
||||
_ = pw.Close()
|
||||
}()
|
||||
err = withOptionFile(target.Values, tc, restTool, func(cnf string) error {
|
||||
return execx.Run(ctx, execx.Cmd{
|
||||
Name: restTool.path, Args: append([]string{"--defaults-extra-file=" + cnf}, restArgs...),
|
||||
Stdin: in, Log: slog.Default(), StderrLevel: slog.LevelWarn,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
fail(fmt.Errorf("restore into %s: %w", target.Name, err))
|
||||
_ = pr.CloseWithError(err)
|
||||
}
|
||||
<-done
|
||||
return cause
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jkaninda/bkupkit/cli"
|
||||
"github.com/jkaninda/bkupkit/config"
|
||||
"github.com/jkaninda/bkupkit/engine"
|
||||
)
|
||||
|
||||
func resolve(t *testing.T, env config.MapEnv) *config.Values {
|
||||
t.Helper()
|
||||
r := config.NewRegistry()
|
||||
config.RegisterCommon(r)
|
||||
New().Settings(r)
|
||||
v, err := r.Resolve(config.ResolveOptions{Env: env})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func TestParseSSLMode(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": SSLDisabled, "0": SSLDisabled, "false": SSLDisabled, "DISABLED": SSLDisabled,
|
||||
"1": SSLRequired, "true": SSLRequired, "Required": SSLRequired,
|
||||
"preferred": SSLPreferred, "VERIFY_CA": SSLVerifyCA, "verify-identity": SSLVerifyIdentity,
|
||||
}
|
||||
for in, want := range cases {
|
||||
got, err := ParseSSLMode(in)
|
||||
if err != nil || got != want {
|
||||
t.Errorf("ParseSSLMode(%q) = %q, %v; want %q", in, got, err, want)
|
||||
}
|
||||
}
|
||||
if _, err := ParseSSLMode("2"); err == nil {
|
||||
t.Error("expected an error for 2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseURL(t *testing.T) {
|
||||
c, err := ParseURL("mysql://bob:p%40ss%3Aw%2Frd@db.example:3307/shop?ssl-mode=verify_identity&ssl-ca=/ca.pem")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := Conn{Host: "db.example", Port: 3307, User: "bob", Password: "p@ss:w/rd", DB: "shop", SSLMode: SSLVerifyIdentity, SSLCA: "/ca.pem"}
|
||||
if c != want {
|
||||
t.Fatalf("got %+v\nwant %+v", c, want)
|
||||
}
|
||||
if _, err := ParseURL("postgres://a@b/c"); err == nil {
|
||||
t.Fatal("expected scheme error")
|
||||
}
|
||||
if _, err := ParseURL("mysql://a@b:99999/c"); err == nil {
|
||||
t.Fatal("expected port error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceConnPrecedence(t *testing.T) {
|
||||
v := resolve(t, config.MapEnv{
|
||||
"DB_URL": "mariadb://u:pw@h1/db1", "DB_HOST": "ignored", "DB_PORT": "3310", "DB_NAME": "ignored",
|
||||
"DB_SSL_MODE": "1",
|
||||
})
|
||||
c, err := SourceConn(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.Host != "h1" || c.Port != 3310 || c.User != "u" || c.Password != "pw" || c.DB != "db1" || c.SSLMode != SSLRequired {
|
||||
t.Fatalf("%+v", c)
|
||||
}
|
||||
if c.SSLCA != DefaultCA {
|
||||
t.Fatalf("default CA = %q", c.SSLCA)
|
||||
}
|
||||
|
||||
v = resolve(t, config.MapEnv{"DB_HOST": "h", "DB_USER": "legacy", "DB_PASSWORD": "x", "DB_NAME": "d"})
|
||||
c, _ = SourceConn(v)
|
||||
if c.User != "legacy" || c.Port != 3306 || c.SSLMode != SSLDisabled {
|
||||
t.Fatalf("%+v", c)
|
||||
}
|
||||
|
||||
v = resolve(t, config.MapEnv{"DB_SSL_MODE": "sometimes"})
|
||||
if err := New().Configure(context.Background(), v); err == nil {
|
||||
t.Fatal("invalid DB_SSL_MODE accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetConn(t *testing.T) {
|
||||
v := resolve(t, config.MapEnv{"DB_NAME": "a"})
|
||||
if _, err := TargetConn(v); err == nil {
|
||||
t.Fatal("expected missing TARGET_DB_HOST")
|
||||
}
|
||||
v = resolve(t, config.MapEnv{"TARGET_DB_HOST": "t", "TARGET_DB_USERNAME": "tu", "TARGET_DB_PASSWORD": "tp", "TARGET_DB_SSL_MODE": "verify_ca"})
|
||||
c, err := TargetConn(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.Host != "t" || c.User != "tu" || c.Password != "tp" || c.Port != 3306 || c.SSLMode != SSLVerifyCA {
|
||||
t.Fatalf("%+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuoteOption(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
`plain`: `"plain"`,
|
||||
`a"b`: `"a\"b"`,
|
||||
`back\slash`: `"back\\slash"`,
|
||||
" # spaced ": `" # spaced "`,
|
||||
"new\nline\t": `"new\nline\t"`,
|
||||
`it's $x`: `"it's $x"`,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := quoteOption(in); got != want {
|
||||
t.Errorf("quoteOption(%q) = %s, want %s", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionFile(t *testing.T) {
|
||||
c := Conn{Host: "db", Port: 3306, User: "root", Password: `p"w#`, SSLMode: SSLDisabled, SSLCA: DefaultCA}
|
||||
got := optionFile(c, false)
|
||||
for _, want := range []string{"[client]\n", "protocol=TCP\n", `host="db"` + "\n", "port=3306\n", `user="root"` + "\n",
|
||||
`password="p\"w#"` + "\n", "ssl-mode=DISABLED\n", "get-server-public-key=1\n"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("mysql option file lacks %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "ssl-ca") {
|
||||
t.Errorf("CA written without verification:\n%s", got)
|
||||
}
|
||||
got = optionFile(c, true)
|
||||
if !strings.Contains(got, "ssl=0\n") || strings.Contains(got, "ssl-mode") || strings.Contains(got, "get-server-public-key") {
|
||||
t.Errorf("mariadb option file:\n%s", got)
|
||||
}
|
||||
|
||||
c.SSLMode = SSLVerifyIdentity
|
||||
if got := optionFile(c, false); !strings.Contains(got, "ssl-mode=VERIFY_IDENTITY\n") || !strings.Contains(got, `ssl-ca="`+DefaultCA+`"`) {
|
||||
t.Errorf("verify_identity:\n%s", got)
|
||||
}
|
||||
if got := optionFile(c, true); !strings.Contains(got, "ssl=1\nssl-verify-server-cert=1\n") || !strings.Contains(got, "ssl-ca=") {
|
||||
t.Errorf("mariadb verify:\n%s", got)
|
||||
}
|
||||
c.SSLMode = SSLRequired
|
||||
if got := optionFile(c, true); !strings.Contains(got, "ssl=1\nssl-verify-server-cert=0\n") || strings.Contains(got, "ssl-ca=") {
|
||||
t.Errorf("mariadb required:\n%s", got)
|
||||
}
|
||||
|
||||
c.Host = "/run/mysqld/mysqld.sock"
|
||||
if got := optionFile(c, true); !strings.Contains(got, "protocol=SOCKET\n") || !strings.Contains(got, `socket="/run/mysqld/mysqld.sock"`) || strings.Contains(got, "host=") {
|
||||
t.Errorf("socket:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDumpArgs(t *testing.T) {
|
||||
base := []string{"--single-transaction", "--routines", "--triggers", "--events", "--hex-blob", "--quick"}
|
||||
cases := []struct {
|
||||
name string
|
||||
toolMaria, srvMaria bool
|
||||
spec dumpSpec
|
||||
want []string
|
||||
}{
|
||||
{"oracle-mysql", false, false, dumpSpec{db: "shop"}, append(append([]string{}, base...), "--no-tablespaces", "--set-gtid-purged=OFF", "shop")},
|
||||
{"maria-maria", true, true, dumpSpec{db: "shop"}, append(append([]string{}, base...), "shop")},
|
||||
{"maria-mysql", true, false, dumpSpec{all: true}, append(append([]string{}, base...), "--no-tablespaces", "--all-databases")},
|
||||
{"oracle-maria", false, true, dumpSpec{databases: []string{"a", "b"}}, append(append([]string{}, base...), "--column-statistics=0", "--databases", "a", "b")},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := DumpArgs(c.toolMaria, c.srvMaria, c.spec); !reflect.DeepEqual(got, c.want) {
|
||||
t.Errorf("%s:\n got %v\nwant %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterDatabases(t *testing.T) {
|
||||
got := filterDatabases([]string{"information_schema", "shop", "mysql", "Performance_Schema", "sys", "blog", "", "tmp\r"}, []string{"tmp"})
|
||||
if !reflect.DeepEqual(got, []string{"shop", "blog"}) {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsMariaDB(t *testing.T) {
|
||||
if !IsMariaDB("11.8.3-MariaDB-ubu2404") || IsMariaDB("8.4.6") {
|
||||
t.Fatal("flavour detection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuoteIdent(t *testing.T) {
|
||||
if got := quoteIdent("we`ird"); got != "`we``ird`" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargets(t *testing.T) {
|
||||
e := New()
|
||||
ctx := context.Background()
|
||||
for _, c := range []struct{ storagePath, file, wantRoot, wantFile string }{
|
||||
{"/backup", "/backup/x.sql.gz", "/backup", "x.sql.gz"},
|
||||
{"/backup", "/backup/sub/x.sql.gz", "/backup", "sub/x.sql.gz"},
|
||||
{"/backup", "/data/x.sql", "/data", "x.sql"},
|
||||
{"/backup", "x.sql", "/backup", "x.sql"},
|
||||
} {
|
||||
v := resolve(t, config.MapEnv{"STORAGE_PATH": c.storagePath, "FILE_NAME": c.file, "DB_NAME": "x"})
|
||||
if _, err := e.Targets(ctx, v); err != nil || v.String(config.StoragePath) != c.wantRoot || v.String(config.FileName) != c.wantFile {
|
||||
t.Errorf("%s: %q %q %v", c.file, v.String(config.StoragePath), v.String(config.FileName), err)
|
||||
}
|
||||
}
|
||||
v0 := resolve(t, config.MapEnv{"STORAGE": "Remote", "DB_NAME": "x"})
|
||||
if _, err := e.Targets(ctx, v0); err != nil || v0.String(config.Storage) != "sftp" {
|
||||
t.Fatalf("storage remote: %q %v", v0.String(config.Storage), err)
|
||||
}
|
||||
v := resolve(t, config.MapEnv{"DB_NAME": "shop"})
|
||||
ts, err := e.Targets(ctx, v)
|
||||
if err != nil || len(ts) != 1 || ts[0].Name != "shop" {
|
||||
t.Fatalf("%v %v", ts, err)
|
||||
}
|
||||
v = resolve(t, config.MapEnv{})
|
||||
ts, err = e.Targets(ctx, v)
|
||||
if err != nil || len(ts) != 1 || ts[0].Name != "" {
|
||||
t.Fatalf("no name: %v %v", ts, err)
|
||||
}
|
||||
var ee *cli.ExitError
|
||||
if err := e.Ping(ctx, ts[0]); !errors.As(err, &ee) || ee.Code != cli.ExitConfig || !strings.Contains(err.Error(), "DB_NAME") {
|
||||
t.Fatalf("backup without a database: %v", err)
|
||||
}
|
||||
// restore without a database (all-in-one dumps carry their own USE).
|
||||
v = resolve(t, config.MapEnv{"FILE_NAME": "all_databases_20260101_000000.sql.gz"})
|
||||
if ts, err := e.Targets(ctx, v); err != nil || ts[0].Name != "" {
|
||||
t.Fatalf("%v %v", ts, err)
|
||||
}
|
||||
v = resolve(t, config.MapEnv{"BACKUP_ALL_IN_ONE": "true"})
|
||||
if ts, err := e.Targets(ctx, v); err != nil || ts[0].Name != AllInOneName || restoreDB(ts[0]) != "" {
|
||||
t.Fatalf("%v %v", ts, err)
|
||||
}
|
||||
v = resolve(t, config.MapEnv{"DB_NAME": "-x"})
|
||||
if _, err := e.Targets(ctx, v); err == nil {
|
||||
t.Fatal("option-like database name accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// fakeClients installs shell scripts standing in for the client binaries.
|
||||
// They log their argv and the option file, and answer the queries the engine runs.
|
||||
func fakeClients(t *testing.T, serverVersion string, oracle bool) (dir, logFile string) {
|
||||
t.Helper()
|
||||
dir = t.TempDir()
|
||||
logFile = filepath.Join(dir, "calls.log")
|
||||
script := `#!/bin/sh
|
||||
log="` + logFile + `"
|
||||
echo "ARGV $(basename "$0") $*" >> "$log"
|
||||
case "$1" in --defaults-extra-file=*) f="${1#--defaults-extra-file=}"; echo "MODE $(ls -ln "$f" | cut -c1-10)" >> "$log"; sed 's/^/CNF /' "$f" >> "$log";; esac
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
--version) echo "$(basename "$0") Ver 8.4.6 for Linux (MySQL Community Server - GPL)"; exit 0;;
|
||||
"SELECT VERSION()") echo "` + serverVersion + `"; exit 0;;
|
||||
"SHOW DATABASES") printf 'information_schema\nmysql\nshop\nblog\nsys\n'; exit 0;;
|
||||
SELECT\ COUNT*) echo 0; exit 0;;
|
||||
CREATE\ DATABASE*) exit 0;;
|
||||
esac
|
||||
done
|
||||
case "$(basename "$0")" in
|
||||
*dump) echo "-- dump of $*";;
|
||||
*) cat > "` + filepath.Join(dir, "restored.sql") + `";;
|
||||
esac
|
||||
`
|
||||
names := []string{"mariadb", "mariadb-dump"}
|
||||
if oracle {
|
||||
names = append(names, "mysql", "mysqldump")
|
||||
}
|
||||
for _, n := range names {
|
||||
if err := os.WriteFile(filepath.Join(dir, n), []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return dir, logFile
|
||||
}
|
||||
|
||||
func engineWith(dir string) *Engine {
|
||||
e := New()
|
||||
e.LookPath = func(name string) (string, error) {
|
||||
p := filepath.Join(dir, name)
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func TestDumpAndRestoreUseOptionFile(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
for _, tc := range []struct {
|
||||
name, version string
|
||||
oracle bool
|
||||
wantDump, wantCL string
|
||||
}{
|
||||
{"mysql", "8.4.6", true, "mysqldump", "mysql"},
|
||||
{"mariadb", "11.8.3-MariaDB", true, "mariadb-dump", "mariadb"},
|
||||
{"mysql-without-oracle-client", "8.4.6", false, "mariadb-dump", "mariadb"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir, logFile := fakeClients(t, tc.version, tc.oracle)
|
||||
tmp := t.TempDir()
|
||||
v := resolve(t, config.MapEnv{"DB_HOST": "db", "DB_USERNAME": "root", "DB_PASSWORD": "s3cr3t pw", "DB_NAME": "shop", "TMP_DIR": tmp})
|
||||
e := engineWith(dir)
|
||||
tg := engine.Target{Name: "shop", Values: v}
|
||||
if err := e.Ping(ctx, tg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := e.ServerVersion(ctx, tg); got != tc.version {
|
||||
t.Fatalf("version %q", got)
|
||||
}
|
||||
var out bytes.Buffer
|
||||
if err := e.Dump(ctx, tg, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(out.String(), "-- dump of --defaults-extra-file=") || !strings.HasSuffix(strings.TrimSpace(out.String()), " shop") {
|
||||
t.Fatalf("dump output %q", out.String())
|
||||
}
|
||||
if err := e.Restore(ctx, tg, strings.NewReader("CREATE TABLE t (id int);\n"), engine.ArtifactInfo{File: "x.sql"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := os.ReadFile(logFile)
|
||||
calls := string(b)
|
||||
if strings.Contains(strings.Join(argvLines(calls), "\n"), "s3cr3t") {
|
||||
t.Fatalf("password in argv:\n%s", calls)
|
||||
}
|
||||
if !strings.Contains(calls, `CNF password="s3cr3t pw"`) {
|
||||
t.Fatalf("password not in option file:\n%s", calls)
|
||||
}
|
||||
if !strings.Contains(calls, "MODE -rw-------") || strings.Contains(calls, "MODE -rw-r") {
|
||||
t.Fatalf("option file mode:\n%s", calls)
|
||||
}
|
||||
for _, l := range argvLines(calls) {
|
||||
f := strings.Fields(l)
|
||||
if len(f) < 3 || !strings.HasPrefix(f[2], "--defaults-extra-file=") {
|
||||
t.Fatalf("--defaults-extra-file is not the first argument: %q", l)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(calls, "ARGV "+tc.wantDump+" ") || !strings.Contains(calls, "ARGV "+tc.wantCL+" --defaults-extra-file=") {
|
||||
t.Fatalf("clients:\n%s", calls)
|
||||
}
|
||||
if !strings.Contains(calls, "--binary-mode shop") {
|
||||
t.Fatalf("restore args:\n%s", calls)
|
||||
}
|
||||
if r, _ := os.ReadFile(filepath.Join(dir, "restored.sql")); string(r) != "CREATE TABLE t (id int);\n" {
|
||||
t.Fatalf("restored %q", r)
|
||||
}
|
||||
if ents, _ := os.ReadDir(tmp); len(ents) != 0 {
|
||||
t.Fatalf("TMP_DIR not cleaned: %v", ents)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func argvLines(log string) []string {
|
||||
var out []string
|
||||
for _, l := range strings.Split(log, "\n") {
|
||||
if strings.HasPrefix(l, "ARGV ") && !strings.Contains(l, "--version") {
|
||||
out = append(out, l)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestAllDatabasesTargets(t *testing.T) {
|
||||
dir, _ := fakeClients(t, "8.4.6", true)
|
||||
v := resolve(t, config.MapEnv{"DB_HOST": "db", "DB_USERNAME": "root", "BACKUP_ALL_DATABASES": "1", "BACKUP_EXCLUDE_DATABASES": "blog", "TMP_DIR": t.TempDir()})
|
||||
ts, err := engineWith(dir).Targets(context.Background(), v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ts) != 1 || ts[0].Name != "shop" {
|
||||
t.Fatalf("%v", ts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigratePipes(t *testing.T) {
|
||||
dir, logFile := fakeClients(t, "11.8.3-MariaDB", true)
|
||||
v := resolve(t, config.MapEnv{"DB_HOST": "src", "DB_USERNAME": "root", "DB_PASSWORD": "a", "DB_NAME": "shop",
|
||||
"TARGET_DB_HOST": "dst", "TARGET_DB_USERNAME": "root", "TARGET_DB_PASSWORD": "b", "TARGET_DB_NAME": "shop2", "TMP_DIR": t.TempDir()})
|
||||
e := engineWith(dir)
|
||||
pairs, err := e.MigrateTargets(context.Background(), v)
|
||||
if err != nil || len(pairs) != 1 || pairs[0][0].Name != "shop" || pairs[0][1].Name != "shop2" {
|
||||
t.Fatalf("%v %v", pairs, err)
|
||||
}
|
||||
if err := e.Migrate(context.Background(), pairs[0][0], pairs[0][1]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, _ := os.ReadFile(filepath.Join(dir, "restored.sql"))
|
||||
if !strings.HasPrefix(string(r), "-- dump of --defaults-extra-file=") {
|
||||
t.Fatalf("restored %q", r)
|
||||
}
|
||||
b, _ := os.ReadFile(logFile)
|
||||
if !strings.Contains(string(b), `CNF host="dst"`) || !strings.Contains(string(b), "--binary-mode shop2") {
|
||||
t.Fatalf("calls:\n%s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifiesBeforeRestore(t *testing.T) {
|
||||
// MySQL cannot roll back DDL: a damaged dump must be caught first.
|
||||
if !New().VerifyBeforeRestore(engine.Target{}, engine.ArtifactInfo{}) {
|
||||
t.Fatal("restores must verify the dump first")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// quoteOption quotes a value for a MySQL/MariaDB option file. Both client
|
||||
// families parse option files with the same mysys code: a value wrapped in
|
||||
// double quotes keeps leading/trailing spaces and '#', and the escapes
|
||||
// \\ \" \n \r \t are decoded inside it.
|
||||
func quoteOption(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s) + 2)
|
||||
b.WriteByte('"')
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '\\':
|
||||
b.WriteString(`\\`)
|
||||
case '"':
|
||||
b.WriteString(`\"`)
|
||||
case '\n':
|
||||
b.WriteString(`\n`)
|
||||
case '\r':
|
||||
b.WriteString(`\r`)
|
||||
case '\t':
|
||||
b.WriteString(`\t`)
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
b.WriteByte('"')
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// optionFile returns the [client] option file for c. mariadbClient selects
|
||||
// the MariaDB client option names (ssl, ssl-verify-server-cert) instead of
|
||||
// the Oracle MySQL ones (ssl-mode, get-server-public-key).
|
||||
//
|
||||
// The file holds the password; it is written 0600 in a private directory and
|
||||
// passed as --defaults-extra-file, never on the command line.
|
||||
func optionFile(c Conn, mariadbClient bool) string {
|
||||
var b strings.Builder
|
||||
line := func(k, v string) {
|
||||
b.WriteString(k)
|
||||
b.WriteByte('=')
|
||||
b.WriteString(v)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
b.WriteString("[client]\n")
|
||||
if strings.HasPrefix(c.Host, "/") {
|
||||
line("protocol", "SOCKET")
|
||||
line("socket", quoteOption(c.Host))
|
||||
} else {
|
||||
// TCP even for "localhost" (a client would otherwise look for a socket).
|
||||
line("protocol", "TCP")
|
||||
line("host", quoteOption(c.Host))
|
||||
line("port", strconv.Itoa(c.Port))
|
||||
}
|
||||
line("user", quoteOption(c.User))
|
||||
line("password", quoteOption(c.Password))
|
||||
line("max-allowed-packet", "1G")
|
||||
|
||||
verify := c.SSLMode == SSLVerifyCA || c.SSLMode == SSLVerifyIdentity
|
||||
if mariadbClient {
|
||||
switch c.SSLMode {
|
||||
case SSLDisabled:
|
||||
line("ssl", "0")
|
||||
case SSLPreferred:
|
||||
// Leave TLS to the client default, without certificate checks.
|
||||
line("ssl-verify-server-cert", "0")
|
||||
case SSLRequired:
|
||||
line("ssl", "1")
|
||||
line("ssl-verify-server-cert", "0")
|
||||
default: // verify_ca, verify_identity: MariaDB always checks the host name too
|
||||
line("ssl", "1")
|
||||
line("ssl-verify-server-cert", "1")
|
||||
}
|
||||
} else {
|
||||
line("ssl-mode", strings.ToUpper(c.SSLMode))
|
||||
if c.SSLMode != SSLVerifyIdentity && c.SSLMode != SSLVerifyCA {
|
||||
// caching_sha2_password over a non-TLS connection needs the
|
||||
// server's RSA key (what the MariaDB client does by itself).
|
||||
line("get-server-public-key", "1")
|
||||
}
|
||||
}
|
||||
if c.SSLMode != SSLDisabled {
|
||||
// The CA is only used when certificates are verified: with the MySQL
|
||||
// client, a CA would otherwise silently turn "required" into "verify_ca".
|
||||
if verify && c.SSLCA != "" {
|
||||
line("ssl-ca", quoteOption(c.SSLCA))
|
||||
}
|
||||
if c.SSLCert != "" {
|
||||
line("ssl-cert", quoteOption(c.SSLCert))
|
||||
}
|
||||
if c.SSLKey != "" {
|
||||
line("ssl-key", quoteOption(c.SSLKey))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FilterOptions say how RestoreFilter adapts a dump.
|
||||
type FilterOptions struct {
|
||||
// Target is the database the dump is restored into ("" for an
|
||||
// all-in-one restore, which is left alone).
|
||||
Target string
|
||||
// NoDefiner removes DEFINER clauses.
|
||||
NoDefiner bool
|
||||
// OracleClient: Oracle's mysql client replays the dump.
|
||||
OracleClient bool
|
||||
// MySQLServer: the dump is restored into MySQL (not MariaDB).
|
||||
MySQLServer bool
|
||||
}
|
||||
|
||||
// RestoreFilter adapts a dump on its way to the client.
|
||||
//
|
||||
// With a Target (a restore into a named database), the dump's CREATE
|
||||
// DATABASE, DROP DATABASE and USE statements are dropped, so a dump taken with
|
||||
// --databases (or written by another tool) lands in target whatever its source
|
||||
// database was called. A dump naming two databases cannot go into one and
|
||||
// fails the restore.
|
||||
//
|
||||
// With NoDefiner, DEFINER clauses are removed from CREATE statements, so
|
||||
// views, triggers, routines and events are defined by the restoring user: only
|
||||
// a user with SET_USER_ID (SUPER) may name another one, and the source
|
||||
// accounts usually do not exist on the target anyway.
|
||||
//
|
||||
// With OracleClient, the MariaDB sandbox line that mariadb-dump writes
|
||||
// first ("/*M!999999\- enable the sandbox mode */") is dropped: Oracle's mysql
|
||||
// client rejects it, so a MariaDB dump could not be restored into MySQL. The
|
||||
// MariaDB client keeps it (it disables client shell commands for the dump).
|
||||
//
|
||||
// With MySQLServer, the utf8mb4_uca1400 collations MariaDB 11 uses by default
|
||||
// become their MySQL equivalents (utf8mb4_uca1400_ai_ci -> utf8mb4_0900_ai_ci),
|
||||
// outside data lines, and sql_mode values keep only the modes MySQL 8 knows
|
||||
// (NO_AUTO_CREATE_USER from MySQL 5.7 and MariaDB, MariaDB-only modes go), so
|
||||
// MariaDB and older MySQL dumps restore into MySQL 8.
|
||||
//
|
||||
// Lines are kept (an empty line replaces a dropped statement), so the client's
|
||||
// error line numbers still match the dump.
|
||||
func RestoreFilter(r io.Reader, o FilterOptions) io.ReadCloser {
|
||||
pr, pw := io.Pipe()
|
||||
go func() {
|
||||
f := &restoreFilter{FilterOptions: o, delimiter: ";"}
|
||||
err := f.run(r, pw)
|
||||
if f.retargeted > 0 || f.definers > 0 || f.collations > 0 || f.sqlModes > 0 {
|
||||
slog.Default().Info("Restore adapted to the target", "source_database", f.source, "database", o.Target,
|
||||
"database_statements_skipped", f.retargeted, "definers_removed", f.definers,
|
||||
"collations_mapped", f.collations, "sql_modes_adjusted", f.sqlModes)
|
||||
}
|
||||
_ = pw.CloseWithError(err)
|
||||
}()
|
||||
return pr
|
||||
}
|
||||
|
||||
const ident = "(`(?:[^`]|``)+`|'(?:[^']|'')+'|\"(?:[^\"]|\"\")+\"|[\\w$]+)"
|
||||
|
||||
var (
|
||||
useRe = regexp.MustCompile(`(?i)^USE\s+` + ident + `\s*;?$`)
|
||||
createDBRe = regexp.MustCompile(`(?i)^CREATE\s+(?:DATABASE|SCHEMA)\s*(?:/\*!\d*\s*IF\s+NOT\s+EXISTS\s*\*/|IF\s+NOT\s+EXISTS)?\s*` + ident + `[^;]*;$`)
|
||||
dropDBRe = regexp.MustCompile(`(?i)^(?:/\*!\d*\s*)?DROP\s+(?:DATABASE|SCHEMA)\s+(?:IF\s+EXISTS\s+)?` + ident + `\s*(?:\*/)?\s*;$`)
|
||||
delimiterRe = regexp.MustCompile(`(?i)^DELIMITER\s+(\S+)`)
|
||||
sandboxRe = regexp.MustCompile(`^/\*M!999999\\- enable the sandbox mode \*/\s*$`)
|
||||
definerPart = "(?:`(?:[^`]|``)*`|'(?:[^']|'')*'|\"(?:[^\"]|\"\")*\"|[\\w.$%-]+)"
|
||||
// MariaDB collations with an exact MySQL 8 equivalent.
|
||||
uca1400Re = regexp.MustCompile(`\butf8mb4_uca1400_(ai_ci|as_ci|as_cs)\b`)
|
||||
dataRe = regexp.MustCompile(`(?i)^(?:INSERT|REPLACE)\s`)
|
||||
sqlModeRe = regexp.MustCompile(`(?i)(\bsql_mode\s*=\s*')([^']*)(')`)
|
||||
definerRe = regexp.MustCompile(`(?i)\s*DEFINER\s*=\s*(?:CURRENT_USER(?:\s*\(\s*\))?|` + definerPart + `\s*@\s*` + definerPart + `|` + definerPart + `)`)
|
||||
)
|
||||
|
||||
const lineBuf = 64 * 1024
|
||||
|
||||
type restoreFilter struct {
|
||||
FilterOptions
|
||||
delimiter string
|
||||
source string // the database the dump selects, once seen
|
||||
|
||||
retargeted, definers, collations, sqlModes int
|
||||
}
|
||||
|
||||
func (f *restoreFilter) run(r io.Reader, w io.Writer) error {
|
||||
br := bufio.NewReaderSize(r, lineBuf)
|
||||
partial := false
|
||||
for {
|
||||
line, err := br.ReadSlice('\n')
|
||||
if len(line) > 0 {
|
||||
out := line
|
||||
if !partial && !errors.Is(err, bufio.ErrBufferFull) {
|
||||
// Long lines are data (extended INSERTs): never rewritten.
|
||||
var ferr error
|
||||
if out, ferr = f.line(line); ferr != nil {
|
||||
return ferr
|
||||
}
|
||||
}
|
||||
if _, werr := w.Write(out); werr != nil {
|
||||
return werr
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, bufio.ErrBufferFull):
|
||||
partial = true
|
||||
case err == nil:
|
||||
partial = false
|
||||
case errors.Is(err, io.EOF):
|
||||
return nil
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *restoreFilter) line(b []byte) ([]byte, error) {
|
||||
text := strings.TrimSpace(string(b))
|
||||
nl := b[len(strings.TrimRight(string(b), "\r\n")):]
|
||||
if f.OracleClient && sandboxRe.MatchString(text) {
|
||||
return nl, nil
|
||||
}
|
||||
if m := delimiterRe.FindStringSubmatch(text); m != nil {
|
||||
f.delimiter = m[1]
|
||||
return b, nil
|
||||
}
|
||||
if f.Target != "" && f.delimiter == ";" {
|
||||
for _, re := range []*regexp.Regexp{useRe, createDBRe, dropDBRe} {
|
||||
m := re.FindStringSubmatch(text)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
name := unquoteIdent(m[1])
|
||||
switch {
|
||||
case f.source == "":
|
||||
f.source = name
|
||||
case name != f.source:
|
||||
return nil, fmt.Errorf("the dump holds more than one database (%s, %s): restore it without a database name, or one database at a time", f.source, name)
|
||||
}
|
||||
f.retargeted++
|
||||
return nl, nil
|
||||
}
|
||||
}
|
||||
out := strings.TrimRight(string(b), "\r\n")
|
||||
changed := false
|
||||
if f.NoDefiner && definesObject(text) {
|
||||
if n := len(definerRe.FindAllStringIndex(out, -1)); n > 0 {
|
||||
f.definers += n
|
||||
out, changed = definerRe.ReplaceAllString(out, ""), true
|
||||
}
|
||||
}
|
||||
if f.MySQLServer && !dataRe.MatchString(text) {
|
||||
if n := len(uca1400Re.FindAllStringIndex(out, -1)); n > 0 {
|
||||
f.collations += n
|
||||
out, changed = uca1400Re.ReplaceAllString(out, "utf8mb4_0900_$1"), true
|
||||
}
|
||||
if sqlModeRe.MatchString(out) {
|
||||
fixed := sqlModeRe.ReplaceAllStringFunc(out, func(m string) string {
|
||||
p := sqlModeRe.FindStringSubmatch(m)
|
||||
return p[1] + mysqlModes(p[2]) + p[3]
|
||||
})
|
||||
if fixed != out {
|
||||
f.sqlModes++
|
||||
out, changed = fixed, true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return b, nil
|
||||
}
|
||||
return append([]byte(out), nl...), nil
|
||||
}
|
||||
|
||||
// definesObject reports whether a line can carry a DEFINER clause: a CREATE
|
||||
// statement, or mysqldump's versioned comments around one.
|
||||
func definesObject(s string) bool {
|
||||
if len(s) < 6 {
|
||||
return false
|
||||
}
|
||||
head := strings.ToUpper(s[:6])
|
||||
return (head == "CREATE" || strings.HasPrefix(head, "/*!")) && strings.Contains(strings.ToUpper(s), "DEFINER")
|
||||
}
|
||||
|
||||
func unquoteIdent(s string) string {
|
||||
if len(s) >= 2 {
|
||||
switch q := s[0]; q {
|
||||
case '`', '\'', '"':
|
||||
if s[len(s)-1] == q {
|
||||
return strings.ReplaceAll(s[1:len(s)-1], string([]byte{q, q}), string(q))
|
||||
}
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// mysql8Modes are the sql_mode values MySQL 8 accepts.
|
||||
var mysql8Modes = map[string]bool{
|
||||
"ALLOW_INVALID_DATES": true, "ANSI_QUOTES": true, "ERROR_FOR_DIVISION_BY_ZERO": true, "HIGH_NOT_PRECEDENCE": true,
|
||||
"IGNORE_SPACE": true, "NO_AUTO_VALUE_ON_ZERO": true, "NO_BACKSLASH_ESCAPES": true, "NO_DIR_IN_CREATE": true,
|
||||
"NO_ENGINE_SUBSTITUTION": true, "NO_UNSIGNED_SUBTRACTION": true, "NO_ZERO_DATE": true, "NO_ZERO_IN_DATE": true,
|
||||
"ONLY_FULL_GROUP_BY": true, "PAD_CHAR_TO_FULL_LENGTH": true, "PIPES_AS_CONCAT": true, "REAL_AS_FLOAT": true,
|
||||
"STRICT_ALL_TABLES": true, "STRICT_TRANS_TABLES": true, "TIME_TRUNCATE_FRACTIONAL": true,
|
||||
"ANSI": true, "TRADITIONAL": true,
|
||||
}
|
||||
|
||||
// mysqlModes keeps the modes of a comma-separated sql_mode MySQL 8 knows.
|
||||
func mysqlModes(modes string) string {
|
||||
var kept []string
|
||||
for _, m := range strings.Split(modes, ",") {
|
||||
if m = strings.TrimSpace(m); m != "" && mysql8Modes[strings.ToUpper(m)] {
|
||||
kept = append(kept, m)
|
||||
}
|
||||
}
|
||||
return strings.Join(kept, ",")
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/iotest"
|
||||
)
|
||||
|
||||
func filter(in, target string, noDefiner bool) (string, error) {
|
||||
return filterFor(in, target, noDefiner, false)
|
||||
}
|
||||
|
||||
func filterFor(in, target string, noDefiner, oracle bool) (string, error) {
|
||||
return filterWith(in, FilterOptions{Target: target, NoDefiner: noDefiner, OracleClient: oracle})
|
||||
}
|
||||
|
||||
func filterWith(in string, o FilterOptions) (string, error) {
|
||||
rc := RestoreFilter(iotest.OneByteReader(strings.NewReader(in)), o)
|
||||
defer func() { _ = rc.Close() }()
|
||||
out, err := io.ReadAll(rc)
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
const sampleDump = "/*!40101 SET NAMES utf8 */;\n" +
|
||||
"-- Current Database: `classicmodels`\n" +
|
||||
"/*!40000 DROP DATABASE IF EXISTS `classicmodels`*/;\n" +
|
||||
"CREATE DATABASE /*!32312 IF NOT EXISTS*/`classicmodels` /*!40100 DEFAULT CHARACTER SET latin1 */;\n" +
|
||||
"\n" +
|
||||
"USE `classicmodels`;\n" +
|
||||
"CREATE TABLE `customers` (`id` int, `note` text);\n" +
|
||||
"INSERT INTO `customers` VALUES (1,'USE `other`;'),(2,'CREATE DEFINER=`a`@`b` VIEW');\n" +
|
||||
"/*!50001 CREATE ALGORITHM=UNDEFINED */\n" +
|
||||
"/*!50013 DEFINER=`u_83c8ac41`@`%` SQL SECURITY DEFINER */\n" +
|
||||
"/*!50001 VIEW `v` AS select 1 */;\n" +
|
||||
"DELIMITER ;;\n" +
|
||||
"CREATE DEFINER=`root`@`localhost` PROCEDURE `p`()\n" +
|
||||
"BEGIN\n" +
|
||||
" SELECT 1;\n" +
|
||||
"END ;;\n" +
|
||||
"/*!50003 CREATE*/ /*!50017 DEFINER='app'@'%'*/ /*!50003 TRIGGER `t` BEFORE INSERT ON `customers` FOR EACH ROW SET NEW.id = 1 */;;\n" +
|
||||
"DELIMITER ;\n"
|
||||
|
||||
func TestRestoreFilterRetargets(t *testing.T) {
|
||||
out, err := filter(sampleDump, "mail", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, gone := range []string{"DROP DATABASE", "CREATE DATABASE", "\nUSE `classicmodels`;"} {
|
||||
if strings.Contains(out, gone) {
|
||||
t.Errorf("%q kept:\n%s", gone, out)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(out, "'USE `other`;'") || !strings.Contains(out, "DEFINER=`root`") {
|
||||
t.Errorf("data or definers changed:\n%s", out)
|
||||
}
|
||||
if strings.Count(out, "\n") != strings.Count(sampleDump, "\n") {
|
||||
t.Errorf("line count changed")
|
||||
}
|
||||
|
||||
// No target (all-in-one): untouched.
|
||||
if out, _ := filter(sampleDump, "", false); out != sampleDump {
|
||||
t.Errorf("all-in-one dump changed:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreFilterDefiners(t *testing.T) {
|
||||
out, err := filter(sampleDump, "mail", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"/*!50013 SQL SECURITY DEFINER */\n",
|
||||
"CREATE PROCEDURE `p`()\n",
|
||||
"/*!50003 CREATE*/ /*!50017*/ /*!50003 TRIGGER",
|
||||
"(2,'CREATE DEFINER=`a`@`b` VIEW')", // data
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("want %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "u_83c8ac41") || strings.Contains(out, "`root`@") || strings.Contains(out, "'app'@") {
|
||||
t.Errorf("definer kept:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreFilterSeveralDatabases(t *testing.T) {
|
||||
_, err := filter("USE `a`;\nCREATE TABLE t(i int);\nUSE `b`;\n", "mail", false)
|
||||
if err == nil || !strings.Contains(err.Error(), "more than one database (a, b)") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if _, err := filter("CREATE DATABASE IF NOT EXISTS shop;\nuse shop\n", "mail", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanSetDefiner(t *testing.T) {
|
||||
for grants, want := range map[string]bool{
|
||||
"GRANT USAGE ON *.* TO `u_1`@`%`\nGRANT ALL PRIVILEGES ON `blog`.* TO `u_1`@`%`": false,
|
||||
"GRANT USAGE ON *.* TO `superman`@`%`": false,
|
||||
"GRANT SELECT, INSERT, SUPER ON *.* TO `root`@`%` WITH GRANT OPTION": true,
|
||||
"GRANT USAGE ON *.* TO `a`@`%`\nGRANT SET_USER_ID,XA_RECOVER_ADMIN ON *.* TO `a`@`%`": true,
|
||||
"GRANT ALL PRIVILEGES ON *.* TO `root`@`localhost` WITH GRANT OPTION": true,
|
||||
"GRANT SET USER ON *.* TO `m`@`%`": true,
|
||||
} {
|
||||
if got := canSetDefiner(grants); got != want {
|
||||
t.Errorf("%q: %v", grants, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreFilterSandbox(t *testing.T) {
|
||||
in := "/*M!999999\\- enable the sandbox mode */ \n-- MariaDB dump 10.20\nCREATE TABLE t (i int);\n"
|
||||
if out, _ := filterFor(in, "mail", false, true); out != "\n-- MariaDB dump 10.20\nCREATE TABLE t (i int);\n" {
|
||||
t.Fatalf("oracle client: %q", out)
|
||||
}
|
||||
if out, _ := filterFor(in, "mail", false, false); out != in {
|
||||
t.Fatalf("mariadb client: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreFilterCollations(t *testing.T) {
|
||||
in := " `name` varchar(100) COLLATE utf8mb4_uca1400_ai_ci DEFAULT NULL,\n" +
|
||||
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_as_cs;\n" +
|
||||
"/*!50003 SET collation_connection = utf8mb4_uca1400_ai_ci */ ;\n" +
|
||||
"INSERT INTO `t` VALUES ('utf8mb4_uca1400_ai_ci');\n" +
|
||||
" `x` text COLLATE utf8mb4_uca1400_swedish_ai_ci,\n"
|
||||
want := " `name` varchar(100) COLLATE utf8mb4_0900_ai_ci DEFAULT NULL,\n" +
|
||||
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs;\n" +
|
||||
"/*!50003 SET collation_connection = utf8mb4_0900_ai_ci */ ;\n" +
|
||||
"INSERT INTO `t` VALUES ('utf8mb4_uca1400_ai_ci');\n" +
|
||||
" `x` text COLLATE utf8mb4_uca1400_swedish_ai_ci,\n"
|
||||
if out, _ := filterWith(in, FilterOptions{Target: "mail", MySQLServer: true}); out != want {
|
||||
t.Fatalf("got:\n%s", out)
|
||||
}
|
||||
if out, _ := filterWith(in, FilterOptions{Target: "mail"}); out != in {
|
||||
t.Fatalf("MariaDB target changed:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreFilterSQLModes(t *testing.T) {
|
||||
in := "/*!50003 SET sql_mode = 'STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION' */ ;\n" +
|
||||
"/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO,SIMULTANEOUS_ASSIGNMENT' */;\n" +
|
||||
"/*!50003 SET sql_mode = @saved_sql_mode */ ;\n" +
|
||||
"INSERT INTO `t` VALUES ('sql_mode=''NO_AUTO_CREATE_USER''');\n"
|
||||
want := "/*!50003 SET sql_mode = 'STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION' */ ;\n" +
|
||||
"/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;\n" +
|
||||
"/*!50003 SET sql_mode = @saved_sql_mode */ ;\n" +
|
||||
"INSERT INTO `t` VALUES ('sql_mode=''NO_AUTO_CREATE_USER''');\n"
|
||||
if out, _ := filterWith(in, FilterOptions{Target: "mail", MySQLServer: true}); out != want {
|
||||
t.Fatalf("got:\n%s", out)
|
||||
}
|
||||
if out, _ := filterWith(in, FilterOptions{Target: "mail"}); out != in {
|
||||
t.Fatalf("MariaDB target changed:\n%s", out)
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,19 @@
|
||||
// Package main /
|
||||
/*****
|
||||
@author Jonas Kaninda
|
||||
@license MIT License <https://opensource.org/licenses/MIT>
|
||||
@Copyright © 2024 Jonas Kaninda
|
||||
**/
|
||||
// Command mysql-bkup backs up and restores MySQL and MariaDB databases to
|
||||
// local storage, S3, GCS, Azure Blob, SFTP or FTP, with optional GPG or age
|
||||
// encryption. See https://github.com/jkaninda/mysql-bkup.
|
||||
package main
|
||||
|
||||
import "github.com/jkaninda/mysql-bkup/cmd"
|
||||
import (
|
||||
"github.com/jkaninda/bkupkit/cli"
|
||||
"github.com/jkaninda/mysql-bkup/internal/mysql"
|
||||
)
|
||||
|
||||
var Version = "development"
|
||||
|
||||
func main() {
|
||||
|
||||
cmd.Execute()
|
||||
|
||||
(&cli.App{
|
||||
Engine: mysql.New(),
|
||||
Version: Version,
|
||||
Short: "Back up and restore MySQL and MariaDB databases (local, S3, GCS, Azure, SFTP, FTP; GPG or age encryption)",
|
||||
}).Main()
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
-- Create the database testdb2 and testdb3
|
||||
CREATE DATABASE IF NOT EXISTS testdb2;
|
||||
CREATE DATABASE IF NOT EXISTS testdb3;
|
||||
CREATE DATABASE IF NOT EXISTS fakedb;
|
||||
USE testdb;
|
||||
|
||||
-- Create the 'users' table
|
||||
CREATE TABLE users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
email VARCHAR(100) NOT NULL UNIQUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create the 'orders' table
|
||||
CREATE TABLE orders (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
amount DECIMAL(10,2) NOT NULL,
|
||||
status ENUM('pending', 'completed', 'canceled') NOT NULL DEFAULT 'pending',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Insert fake users
|
||||
INSERT INTO users (name, email) VALUES
|
||||
('Alice Smith', 'alice@example.com'),
|
||||
('Bob Johnson', 'bob@example.com'),
|
||||
('Charlie Brown', 'charlie@example.com');
|
||||
|
||||
-- Insert fake orders
|
||||
INSERT INTO orders (user_id, amount, status) VALUES
|
||||
(1, 100.50, 'completed'),
|
||||
(2, 200.75, 'pending'),
|
||||
(3, 50.00, 'canceled');
|
||||
@@ -0,0 +1,13 @@
|
||||
#cronExpression: "@every 20s"
|
||||
#backupRescueMode: false
|
||||
databases:
|
||||
- host: 127.0.0.1
|
||||
port: 3306
|
||||
name: testdb
|
||||
user: user
|
||||
password: password
|
||||
- name: testdb2
|
||||
# database credentials from environment variables
|
||||
#TESTDB2_DB_USER
|
||||
#TESTDB2_DB_PASSWORD
|
||||
#TESTDB2_DB_HOST
|
||||
-299
@@ -1,299 +0,0 @@
|
||||
// Package pkg /
|
||||
/*****
|
||||
@author Jonas Kaninda
|
||||
@license MIT License <https://opensource.org/licenses/MIT>
|
||||
@Copyright © 2024 Jonas Kaninda
|
||||
**/
|
||||
package pkg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/hpcloud/tail"
|
||||
"github.com/jkaninda/mysql-bkup/utils"
|
||||
"github.com/spf13/cobra"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
func StartBackup(cmd *cobra.Command) {
|
||||
intro()
|
||||
//Set env
|
||||
utils.SetEnv("STORAGE_PATH", storagePath)
|
||||
utils.GetEnv(cmd, "period", "BACKUP_CRON_EXPRESSION")
|
||||
|
||||
//Get flag value and set env
|
||||
remotePath := utils.GetEnv(cmd, "path", "SSH_REMOTE_PATH")
|
||||
storage = utils.GetEnv(cmd, "storage", "STORAGE")
|
||||
file = utils.GetEnv(cmd, "file", "FILE_NAME")
|
||||
backupRetention, _ := cmd.Flags().GetInt("keep-last")
|
||||
prune, _ := cmd.Flags().GetBool("prune")
|
||||
disableCompression, _ = cmd.Flags().GetBool("disable-compression")
|
||||
executionMode, _ = cmd.Flags().GetString("mode")
|
||||
gpqPassphrase := os.Getenv("GPG_PASSPHRASE")
|
||||
_ = utils.GetEnv(cmd, "path", "AWS_S3_PATH")
|
||||
|
||||
dbConf = getDbConfig(cmd)
|
||||
|
||||
//
|
||||
if gpqPassphrase != "" {
|
||||
encryption = true
|
||||
}
|
||||
|
||||
//Generate file name
|
||||
backupFileName := fmt.Sprintf("%s_%s.sql.gz", dbConf.dbName, time.Now().Format("20060102_150405"))
|
||||
if disableCompression {
|
||||
backupFileName = fmt.Sprintf("%s_%s.sql", dbConf.dbName, time.Now().Format("20060102_150405"))
|
||||
}
|
||||
|
||||
if executionMode == "default" {
|
||||
switch storage {
|
||||
case "s3":
|
||||
s3Backup(dbConf, backupFileName, disableCompression, prune, backupRetention, encryption)
|
||||
case "local":
|
||||
localBackup(dbConf, backupFileName, disableCompression, prune, backupRetention, encryption)
|
||||
case "ssh", "remote":
|
||||
sshBackup(dbConf, backupFileName, remotePath, disableCompression, prune, backupRetention, encryption)
|
||||
case "ftp":
|
||||
utils.Fatal("Not supported storage type: %s", storage)
|
||||
default:
|
||||
localBackup(dbConf, backupFileName, disableCompression, prune, backupRetention, encryption)
|
||||
}
|
||||
|
||||
} else if executionMode == "scheduled" {
|
||||
scheduledMode(dbConf, storage)
|
||||
} else {
|
||||
utils.Fatal("Error, unknown execution mode!")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Run in scheduled mode
|
||||
func scheduledMode(db *dbConfig, storage string) {
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("**********************************")
|
||||
fmt.Println(" Starting MySQL Bkup... ")
|
||||
fmt.Println("***********************************")
|
||||
utils.Info("Running in Scheduled mode")
|
||||
utils.Info("Execution period %s", os.Getenv("BACKUP_CRON_EXPRESSION"))
|
||||
utils.Info("Storage type %s ", storage)
|
||||
|
||||
//Test database connexion
|
||||
testDatabaseConnection(db)
|
||||
|
||||
utils.Info("Creating backup job...")
|
||||
CreateCrontabScript(disableCompression, storage)
|
||||
|
||||
supervisorConfig := "/etc/supervisor/supervisord.conf"
|
||||
|
||||
// Start Supervisor
|
||||
cmd := exec.Command("supervisord", "-c", supervisorConfig)
|
||||
err := cmd.Start()
|
||||
if err != nil {
|
||||
utils.Fatal(fmt.Sprintf("Failed to start supervisord: %v", err))
|
||||
}
|
||||
utils.Info("Backup job started")
|
||||
defer func() {
|
||||
if err := cmd.Process.Kill(); err != nil {
|
||||
utils.Info("Failed to kill supervisord process: %v", err)
|
||||
} else {
|
||||
utils.Info("Supervisor stopped.")
|
||||
}
|
||||
}()
|
||||
if _, err := os.Stat(cronLogFile); os.IsNotExist(err) {
|
||||
utils.Fatal(fmt.Sprintf("Log file %s does not exist.", cronLogFile))
|
||||
}
|
||||
t, err := tail.TailFile(cronLogFile, tail.Config{Follow: true})
|
||||
if err != nil {
|
||||
utils.Fatal("Failed to tail file: %v", err)
|
||||
}
|
||||
|
||||
// Read and print new lines from the log file
|
||||
for line := range t.Lines {
|
||||
fmt.Println(line.Text)
|
||||
}
|
||||
}
|
||||
func intro() {
|
||||
utils.Info("Starting MySQL Backup...")
|
||||
utils.Info("Copyright © 2024 Jonas Kaninda ")
|
||||
}
|
||||
|
||||
// BackupDatabase backup database
|
||||
func BackupDatabase(db *dbConfig, backupFileName string, disableCompression bool) {
|
||||
storagePath = os.Getenv("STORAGE_PATH")
|
||||
|
||||
err := utils.CheckEnvVars(dbHVars)
|
||||
if err != nil {
|
||||
utils.Error("Please make sure all required environment variables for database are set")
|
||||
utils.Fatal("Error checking environment variables: %s", err)
|
||||
}
|
||||
|
||||
utils.Info("Starting database backup...")
|
||||
err = os.Setenv("MYSQL_PWD", db.dbPassword)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
testDatabaseConnection(db)
|
||||
|
||||
// Backup Database database
|
||||
utils.Info("Backing up database...")
|
||||
|
||||
if disableCompression {
|
||||
// Execute mysqldump
|
||||
cmd := exec.Command("mysqldump",
|
||||
"-h", db.dbHost,
|
||||
"-P", db.dbPort,
|
||||
"-u", db.dbUserName,
|
||||
db.dbName,
|
||||
)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// save output
|
||||
file, err := os.Create(fmt.Sprintf("%s/%s", tmpPath, backupFileName))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = file.Write(output)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
utils.Done("Database has been backed up")
|
||||
|
||||
} else {
|
||||
// Execute mysqldump
|
||||
cmd := exec.Command("mysqldump", "-h", db.dbHost, "-P", db.dbPort, "-u", db.dbUserName, db.dbName)
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
gzipCmd := exec.Command("gzip")
|
||||
gzipCmd.Stdin = stdout
|
||||
gzipCmd.Stdout, err = os.Create(fmt.Sprintf("%s/%s", tmpPath, backupFileName))
|
||||
gzipCmd.Start()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := cmd.Run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := gzipCmd.Wait(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
utils.Done("Database has been backed up")
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
func localBackup(db *dbConfig, backupFileName string, disableCompression bool, prune bool, backupRetention int, encrypt bool) {
|
||||
utils.Info("Backup database to local storage")
|
||||
BackupDatabase(db, backupFileName, disableCompression)
|
||||
finalFileName := backupFileName
|
||||
if encrypt {
|
||||
encryptBackup(backupFileName)
|
||||
finalFileName = fmt.Sprintf("%s.%s", backupFileName, gpgExtension)
|
||||
}
|
||||
utils.Info("Backup name is %s", finalFileName)
|
||||
moveToBackup(finalFileName, storagePath)
|
||||
//Send notification
|
||||
utils.NotifySuccess(finalFileName)
|
||||
//Delete old backup
|
||||
if prune {
|
||||
deleteOldBackup(backupRetention)
|
||||
}
|
||||
//Delete temp
|
||||
deleteTemp()
|
||||
}
|
||||
|
||||
func s3Backup(db *dbConfig, backupFileName string, disableCompression bool, prune bool, backupRetention int, encrypt bool) {
|
||||
bucket := utils.GetEnvVariable("AWS_S3_BUCKET_NAME", "BUCKET_NAME")
|
||||
s3Path := utils.GetEnvVariable("AWS_S3_PATH", "S3_PATH")
|
||||
utils.Info("Backup database to s3 storage")
|
||||
//Backup database
|
||||
BackupDatabase(db, backupFileName, disableCompression)
|
||||
finalFileName := backupFileName
|
||||
if encrypt {
|
||||
encryptBackup(backupFileName)
|
||||
finalFileName = fmt.Sprintf("%s.%s", backupFileName, "gpg")
|
||||
}
|
||||
utils.Info("Uploading backup archive to remote storage S3 ... ")
|
||||
utils.Info("Backup name is %s", finalFileName)
|
||||
err := utils.UploadFileToS3(tmpPath, finalFileName, bucket, s3Path)
|
||||
if err != nil {
|
||||
utils.Fatal("Error uploading file to S3: %s ", err)
|
||||
|
||||
}
|
||||
|
||||
//Delete backup file from tmp folder
|
||||
err = utils.DeleteFile(filepath.Join(tmpPath, backupFileName))
|
||||
if err != nil {
|
||||
fmt.Println("Error deleting file: ", err)
|
||||
|
||||
}
|
||||
// Delete old backup
|
||||
if prune {
|
||||
err := utils.DeleteOldBackup(bucket, s3Path, backupRetention)
|
||||
if err != nil {
|
||||
utils.Fatal("Error deleting old backup from S3: %s ", err)
|
||||
}
|
||||
}
|
||||
utils.Done("Uploading backup archive to remote storage S3 ... done ")
|
||||
//Send notification
|
||||
utils.NotifySuccess(finalFileName)
|
||||
//Delete temp
|
||||
deleteTemp()
|
||||
}
|
||||
|
||||
// sshBackup backup database to SSH remote server
|
||||
func sshBackup(db *dbConfig, backupFileName, remotePath string, disableCompression bool, prune bool, backupRetention int, encrypt bool) {
|
||||
utils.Info("Backup database to Remote server")
|
||||
//Backup database
|
||||
BackupDatabase(db, backupFileName, disableCompression)
|
||||
finalFileName := backupFileName
|
||||
if encrypt {
|
||||
encryptBackup(backupFileName)
|
||||
finalFileName = fmt.Sprintf("%s.%s", backupFileName, "gpg")
|
||||
}
|
||||
utils.Info("Uploading backup archive to remote storage ... ")
|
||||
utils.Info("Backup name is %s", finalFileName)
|
||||
err := CopyToRemote(finalFileName, remotePath)
|
||||
if err != nil {
|
||||
utils.Fatal("Error uploading file to the remote server: %s ", err)
|
||||
|
||||
}
|
||||
|
||||
//Delete backup file from tmp folder
|
||||
err = utils.DeleteFile(filepath.Join(tmpPath, finalFileName))
|
||||
if err != nil {
|
||||
fmt.Println("Error deleting file: ", err)
|
||||
|
||||
}
|
||||
if prune {
|
||||
//TODO: Delete old backup from remote server
|
||||
utils.Info("Deleting old backup from a remote server is not implemented yet")
|
||||
|
||||
}
|
||||
|
||||
utils.Done("Uploading backup archive to remote storage ... done ")
|
||||
//Send notification
|
||||
utils.NotifySuccess(finalFileName)
|
||||
//Delete temp
|
||||
deleteTemp()
|
||||
}
|
||||
|
||||
// encryptBackup encrypt backup
|
||||
func encryptBackup(backupFileName string) {
|
||||
gpgPassphrase := os.Getenv("GPG_PASSPHRASE")
|
||||
err := Encrypt(filepath.Join(tmpPath, backupFileName), gpgPassphrase)
|
||||
if err != nil {
|
||||
utils.Fatal("Error during encrypting backup %s", err)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
// Package pkg /
|
||||
/*****
|
||||
@author Jonas Kaninda
|
||||
@license MIT License <https://opensource.org/licenses/MIT>
|
||||
@Copyright © 2024 Jonas Kaninda
|
||||
**/
|
||||
package pkg
|
||||
|
||||
import (
|
||||
"github.com/jkaninda/mysql-bkup/utils"
|
||||
"github.com/spf13/cobra"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
}
|
||||
|
||||
type dbConfig struct {
|
||||
dbHost string
|
||||
dbPort string
|
||||
dbName string
|
||||
dbUserName string
|
||||
dbPassword string
|
||||
}
|
||||
type targetDbConfig struct {
|
||||
targetDbHost string
|
||||
targetDbPort string
|
||||
targetDbUserName string
|
||||
targetDbPassword string
|
||||
targetDbName string
|
||||
}
|
||||
|
||||
func getDbConfig(cmd *cobra.Command) *dbConfig {
|
||||
//Set env
|
||||
utils.GetEnv(cmd, "dbname", "DB_NAME")
|
||||
dConf := dbConfig{}
|
||||
dConf.dbHost = os.Getenv("DB_HOST")
|
||||
dConf.dbPort = os.Getenv("DB_PORT")
|
||||
dConf.dbName = os.Getenv("DB_NAME")
|
||||
dConf.dbUserName = os.Getenv("DB_USERNAME")
|
||||
dConf.dbPassword = os.Getenv("DB_PASSWORD")
|
||||
|
||||
err := utils.CheckEnvVars(dbHVars)
|
||||
if err != nil {
|
||||
utils.Error("Please make sure all required environment variables for database are set")
|
||||
utils.Fatal("Error checking environment variables: %s", err)
|
||||
}
|
||||
return &dConf
|
||||
}
|
||||
func getTargetDbConfig() *targetDbConfig {
|
||||
tdbConfig := targetDbConfig{}
|
||||
tdbConfig.targetDbHost = os.Getenv("TARGET_DB_HOST")
|
||||
tdbConfig.targetDbPort = os.Getenv("TARGET_DB_PORT")
|
||||
tdbConfig.targetDbName = os.Getenv("TARGET_DB_NAME")
|
||||
tdbConfig.targetDbUserName = os.Getenv("TARGET_DB_USERNAME")
|
||||
tdbConfig.targetDbPassword = os.Getenv("TARGET_DB_PASSWORD")
|
||||
|
||||
err := utils.CheckEnvVars(tdbRVars)
|
||||
if err != nil {
|
||||
utils.Error("Please make sure all required environment variables for the target database are set")
|
||||
utils.Fatal("Error checking target database environment variables: %s", err)
|
||||
}
|
||||
return &tdbConfig
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
// Package pkg /
|
||||
/*****
|
||||
@author Jonas Kaninda
|
||||
@license MIT License <https://opensource.org/licenses/MIT>
|
||||
@Copyright © 2024 Jonas Kaninda
|
||||
**/
|
||||
package pkg
|
||||
|
||||
import (
|
||||
"github.com/jkaninda/mysql-bkup/utils"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Decrypt(inputFile string, passphrase string) error {
|
||||
utils.Info("Decrypting backup file: " + inputFile + " ...")
|
||||
//Create gpg home dir
|
||||
err := utils.MakeDir(gpgHome)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
utils.SetEnv("GNUPGHOME", gpgHome)
|
||||
cmd := exec.Command("gpg", "--batch", "--passphrase", passphrase, "--output", RemoveLastExtension(inputFile), "--decrypt", inputFile)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
utils.Info("Backup file decrypted successful!")
|
||||
return nil
|
||||
}
|
||||
|
||||
func Encrypt(inputFile string, passphrase string) error {
|
||||
utils.Info("Encrypting backup...")
|
||||
//Create gpg home dir
|
||||
err := utils.MakeDir(gpgHome)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
utils.SetEnv("GNUPGHOME", gpgHome)
|
||||
cmd := exec.Command("gpg", "--batch", "--passphrase", passphrase, "--symmetric", "--cipher-algo", algorithm, inputFile)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
utils.Info("Backup file encrypted successful!")
|
||||
return nil
|
||||
}
|
||||
|
||||
func RemoveLastExtension(filename string) string {
|
||||
if idx := strings.LastIndex(filename, "."); idx != -1 {
|
||||
return filename[:idx]
|
||||
}
|
||||
return filename
|
||||
}
|
||||
-127
@@ -1,127 +0,0 @@
|
||||
// Package pkg /
|
||||
/*****
|
||||
@author Jonas Kaninda
|
||||
@license MIT License <https://opensource.org/licenses/MIT>
|
||||
@Copyright © 2024 Jonas Kaninda
|
||||
**/
|
||||
package pkg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"github.com/jkaninda/mysql-bkup/utils"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
func copyToTmp(sourcePath string, backupFileName string) {
|
||||
//Copy backup from storage to /tmp
|
||||
err := utils.CopyFile(filepath.Join(sourcePath, backupFileName), filepath.Join(tmpPath, backupFileName))
|
||||
if err != nil {
|
||||
utils.Fatal(fmt.Sprintf("Error copying file %s %s", backupFileName, err))
|
||||
|
||||
}
|
||||
}
|
||||
func moveToBackup(backupFileName string, destinationPath string) {
|
||||
//Copy backup from tmp folder to storage destination
|
||||
err := utils.CopyFile(filepath.Join(tmpPath, backupFileName), filepath.Join(destinationPath, backupFileName))
|
||||
if err != nil {
|
||||
utils.Fatal(fmt.Sprintf("Error copying file %s %s", backupFileName, err))
|
||||
|
||||
}
|
||||
//Delete backup file from tmp folder
|
||||
err = utils.DeleteFile(filepath.Join(tmpPath, backupFileName))
|
||||
if err != nil {
|
||||
fmt.Println("Error deleting file:", err)
|
||||
|
||||
}
|
||||
utils.Done("Database has been backed up and copied to %s", filepath.Join(destinationPath, backupFileName))
|
||||
}
|
||||
func deleteOldBackup(retentionDays int) {
|
||||
utils.Info("Deleting old backups...")
|
||||
storagePath = os.Getenv("STORAGE_PATH")
|
||||
// Define the directory path
|
||||
backupDir := storagePath + "/"
|
||||
// Get current time
|
||||
currentTime := time.Now()
|
||||
// Delete file
|
||||
deleteFile := func(filePath string) error {
|
||||
err := os.Remove(filePath)
|
||||
if err != nil {
|
||||
utils.Fatal(fmt.Sprintf("Error: %s", err))
|
||||
} else {
|
||||
utils.Done("File %s has been deleted successfully", filePath)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Walk through the directory and delete files modified more than specified days ago
|
||||
err := filepath.Walk(backupDir, func(filePath string, fileInfo os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Check if it's a regular file and if it was modified more than specified days ago
|
||||
if fileInfo.Mode().IsRegular() {
|
||||
timeDiff := currentTime.Sub(fileInfo.ModTime())
|
||||
if timeDiff.Hours() > 24*float64(retentionDays) {
|
||||
err := deleteFile(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
utils.Fatal(fmt.Sprintf("Error: %s", err))
|
||||
return
|
||||
}
|
||||
utils.Done("Deleting old backups...done")
|
||||
|
||||
}
|
||||
func deleteTemp() {
|
||||
utils.Info("Deleting %s ...", tmpPath)
|
||||
err := filepath.Walk(tmpPath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Check if the current item is a file
|
||||
if !info.IsDir() {
|
||||
// Delete the file
|
||||
err = os.Remove(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
utils.Error("Error deleting files: %v", err)
|
||||
} else {
|
||||
utils.Info("Deleting %s ... done", tmpPath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDatabaseConnection tests the database connection
|
||||
func testDatabaseConnection(db *dbConfig) {
|
||||
err := os.Setenv("MYSQL_PWD", db.dbPassword)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
utils.Info("Connecting to %s database ...", db.dbName)
|
||||
cmd := exec.Command("mysql", "-h", db.dbHost, "-P", db.dbPort, "-u", db.dbUserName, db.dbName, "-e", "quit")
|
||||
// Capture the output
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &out
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
utils.Fatal("Error testing database connection: %v\nOutput: %s", err, out.String())
|
||||
|
||||
}
|
||||
utils.Info("Successfully connected to %s database", db.dbName)
|
||||
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
// Package pkg /
|
||||
/*****
|
||||
@author Jonas Kaninda
|
||||
@license MIT License <https://opensource.org/licenses/MIT>
|
||||
@Copyright © 2024 Jonas Kaninda
|
||||
**/
|
||||
package pkg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/jkaninda/mysql-bkup/utils"
|
||||
"github.com/spf13/cobra"
|
||||
"time"
|
||||
)
|
||||
|
||||
func StartMigration(cmd *cobra.Command) {
|
||||
intro()
|
||||
utils.Info("Starting database migration...")
|
||||
//Get DB config
|
||||
dbConf = getDbConfig(cmd)
|
||||
targetDbConf = getTargetDbConfig()
|
||||
|
||||
//Defining the target database variables
|
||||
newDbConfig := dbConfig{}
|
||||
newDbConfig.dbHost = targetDbConf.targetDbHost
|
||||
newDbConfig.dbPort = targetDbConf.targetDbPort
|
||||
newDbConfig.dbName = targetDbConf.targetDbName
|
||||
newDbConfig.dbUserName = targetDbConf.targetDbUserName
|
||||
newDbConfig.dbPassword = targetDbConf.targetDbPassword
|
||||
|
||||
//Generate file name
|
||||
backupFileName := fmt.Sprintf("%s_%s.sql", dbConf.dbName, time.Now().Format("20060102_150405"))
|
||||
//Backup source Database
|
||||
BackupDatabase(dbConf, backupFileName, true)
|
||||
//Restore source database into target database
|
||||
utils.Info("Restoring [%s] database into [%s] database...", dbConf.dbName, targetDbConf.targetDbName)
|
||||
RestoreDatabase(&newDbConfig, backupFileName)
|
||||
utils.Info("[%s] database has been restored into [%s] database", dbConf.dbName, targetDbConf.targetDbName)
|
||||
utils.Info("Database migration completed.")
|
||||
}
|
||||
-136
@@ -1,136 +0,0 @@
|
||||
// Package pkg /
|
||||
/*****
|
||||
@author Jonas Kaninda
|
||||
@license MIT License <https://opensource.org/licenses/MIT>
|
||||
@Copyright © 2024 Jonas Kaninda
|
||||
**/
|
||||
package pkg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/jkaninda/mysql-bkup/utils"
|
||||
"github.com/spf13/cobra"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func StartRestore(cmd *cobra.Command) {
|
||||
intro()
|
||||
//Set env
|
||||
utils.SetEnv("STORAGE_PATH", storagePath)
|
||||
|
||||
//Get flag value and set env
|
||||
s3Path := utils.GetEnv(cmd, "path", "AWS_S3_PATH")
|
||||
remotePath := utils.GetEnv(cmd, "path", "SSH_REMOTE_PATH")
|
||||
storage = utils.GetEnv(cmd, "storage", "STORAGE")
|
||||
file = utils.GetEnv(cmd, "file", "FILE_NAME")
|
||||
executionMode, _ = cmd.Flags().GetString("mode")
|
||||
bucket := utils.GetEnvVariable("AWS_S3_BUCKET_NAME", "BUCKET_NAME")
|
||||
dbConf = getDbConfig(cmd)
|
||||
|
||||
switch storage {
|
||||
case "s3":
|
||||
restoreFromS3(dbConf, file, bucket, s3Path)
|
||||
case "local":
|
||||
utils.Info("Restore database from local")
|
||||
copyToTmp(storagePath, file)
|
||||
RestoreDatabase(dbConf, file)
|
||||
case "ssh":
|
||||
restoreFromRemote(dbConf, file, remotePath)
|
||||
case "ftp":
|
||||
utils.Fatal("Restore from FTP is not yet supported")
|
||||
default:
|
||||
utils.Info("Restore database from local")
|
||||
copyToTmp(storagePath, file)
|
||||
RestoreDatabase(dbConf, file)
|
||||
}
|
||||
}
|
||||
|
||||
func restoreFromS3(db *dbConfig, file, bucket, s3Path string) {
|
||||
utils.Info("Restore database from s3")
|
||||
err := utils.DownloadFile(tmpPath, file, bucket, s3Path)
|
||||
if err != nil {
|
||||
utils.Fatal("Error download file from s3 %s %v", file, err)
|
||||
}
|
||||
RestoreDatabase(db, file)
|
||||
}
|
||||
func restoreFromRemote(db *dbConfig, file, remotePath string) {
|
||||
utils.Info("Restore database from remote server")
|
||||
err := CopyFromRemote(file, remotePath)
|
||||
if err != nil {
|
||||
utils.Fatal("Error download file from remote server: %s %v ", filepath.Join(remotePath, file), err)
|
||||
}
|
||||
RestoreDatabase(db, file)
|
||||
}
|
||||
|
||||
// RestoreDatabase restore database
|
||||
func RestoreDatabase(db *dbConfig, file string) {
|
||||
gpgPassphrase := os.Getenv("GPG_PASSPHRASE")
|
||||
if file == "" {
|
||||
utils.Fatal("Error, file required")
|
||||
}
|
||||
|
||||
err := utils.CheckEnvVars(dbHVars)
|
||||
if err != nil {
|
||||
utils.Error("Please make sure all required environment variables for database are set")
|
||||
utils.Fatal("Error checking environment variables: %s", err)
|
||||
}
|
||||
|
||||
extension := filepath.Ext(fmt.Sprintf("%s/%s", tmpPath, file))
|
||||
if extension == ".gpg" {
|
||||
if gpgPassphrase == "" {
|
||||
utils.Fatal("Error: GPG passphrase is required, your file seems to be a GPG file.\nYou need to provide GPG keys. GPG_PASSPHRASE environment variable is required.")
|
||||
|
||||
} else {
|
||||
//Decrypt file
|
||||
err := Decrypt(filepath.Join(tmpPath, file), gpgPassphrase)
|
||||
if err != nil {
|
||||
utils.Fatal("Error decrypting file %s %v", file, err)
|
||||
}
|
||||
//Update file name
|
||||
file = RemoveLastExtension(file)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if utils.FileExists(fmt.Sprintf("%s/%s", tmpPath, file)) {
|
||||
err = os.Setenv("MYSQL_PWD", db.dbPassword)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
testDatabaseConnection(db)
|
||||
utils.Info("Restoring database...")
|
||||
|
||||
extension := filepath.Ext(fmt.Sprintf("%s/%s", tmpPath, file))
|
||||
// Restore from compressed file / .sql.gz
|
||||
if extension == ".gz" {
|
||||
str := "zcat " + filepath.Join(tmpPath, file) + " | mysql -h " + db.dbHost + " -P " + db.dbPort + " -u " + db.dbUserName + " " + db.dbName
|
||||
_, err := exec.Command("bash", "-c", str).Output()
|
||||
if err != nil {
|
||||
utils.Fatal("Error, in restoring the database %v", err)
|
||||
}
|
||||
utils.Info("Restoring database... done")
|
||||
utils.Done("Database has been restored")
|
||||
//Delete temp
|
||||
deleteTemp()
|
||||
|
||||
} else if extension == ".sql" {
|
||||
//Restore from sql file
|
||||
str := "cat " + filepath.Join(tmpPath, file) + " | mysql -h " + db.dbHost + " -P " + db.dbPort + " -u " + db.dbUserName + " " + db.dbName
|
||||
_, err := exec.Command("bash", "-c", str).Output()
|
||||
if err != nil {
|
||||
utils.Fatal("Error in restoring the database %v", err)
|
||||
}
|
||||
utils.Info("Restoring database... done")
|
||||
utils.Done("Database has been restored")
|
||||
//Delete temp
|
||||
deleteTemp()
|
||||
} else {
|
||||
utils.Fatal("Unknown file extension %s", extension)
|
||||
}
|
||||
|
||||
} else {
|
||||
utils.Fatal("File not found in %s", filepath.Join(tmpPath, file))
|
||||
}
|
||||
}
|
||||
-121
@@ -1,121 +0,0 @@
|
||||
// Package pkg /
|
||||
/*****
|
||||
@author Jonas Kaninda
|
||||
@license MIT License <https://opensource.org/licenses/MIT>
|
||||
@Copyright © 2024 Jonas Kaninda
|
||||
**/
|
||||
package pkg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/bramvdbogaerde/go-scp"
|
||||
"github.com/bramvdbogaerde/go-scp/auth"
|
||||
"github.com/jkaninda/mysql-bkup/utils"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func CopyToRemote(fileName, remotePath string) error {
|
||||
sshUser := os.Getenv("SSH_USER")
|
||||
sshPassword := os.Getenv("SSH_PASSWORD")
|
||||
sshHostName := os.Getenv("SSH_HOST_NAME")
|
||||
sshPort := os.Getenv("SSH_PORT")
|
||||
sshIdentifyFile := os.Getenv("SSH_IDENTIFY_FILE")
|
||||
|
||||
err := utils.CheckEnvVars(sshHVars)
|
||||
if err != nil {
|
||||
utils.Error("Error checking environment variables: %s", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
clientConfig, _ := auth.PasswordKey(sshUser, sshPassword, ssh.InsecureIgnoreHostKey())
|
||||
if sshIdentifyFile != "" && utils.FileExists(sshIdentifyFile) {
|
||||
clientConfig, _ = auth.PrivateKey(sshUser, sshIdentifyFile, ssh.InsecureIgnoreHostKey())
|
||||
|
||||
} else {
|
||||
if sshPassword == "" {
|
||||
return errors.New("SSH_PASSWORD environment variable is required if SSH_IDENTIFY_FILE is empty")
|
||||
}
|
||||
utils.Warn("Accessing the remote server using password, password is not recommended")
|
||||
clientConfig, _ = auth.PasswordKey(sshUser, sshPassword, ssh.InsecureIgnoreHostKey())
|
||||
|
||||
}
|
||||
// Create a new SCP client
|
||||
client := scp.NewClient(fmt.Sprintf("%s:%s", sshHostName, sshPort), &clientConfig)
|
||||
|
||||
// Connect to the remote server
|
||||
err = client.Connect()
|
||||
if err != nil {
|
||||
return errors.New("Couldn't establish a connection to the remote server")
|
||||
}
|
||||
|
||||
// Open a file
|
||||
file, _ := os.Open(filepath.Join(tmpPath, fileName))
|
||||
|
||||
// Close client connection after the file has been copied
|
||||
defer client.Close()
|
||||
// Close the file after it has been copied
|
||||
defer file.Close()
|
||||
// the context can be adjusted to provide time-outs or inherit from other contexts if this is embedded in a larger application.
|
||||
err = client.CopyFromFile(context.Background(), *file, filepath.Join(remotePath, fileName), "0655")
|
||||
if err != nil {
|
||||
fmt.Println("Error while copying file ")
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CopyFromRemote(fileName, remotePath string) error {
|
||||
sshUser := os.Getenv("SSH_USER")
|
||||
sshPassword := os.Getenv("SSH_PASSWORD")
|
||||
sshHostName := os.Getenv("SSH_HOST_NAME")
|
||||
sshPort := os.Getenv("SSH_PORT")
|
||||
sshIdentifyFile := os.Getenv("SSH_IDENTIFY_FILE")
|
||||
|
||||
err := utils.CheckEnvVars(sshHVars)
|
||||
if err != nil {
|
||||
utils.Error("Error checking environment variables\n: %s", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
clientConfig, _ := auth.PasswordKey(sshUser, sshPassword, ssh.InsecureIgnoreHostKey())
|
||||
if sshIdentifyFile != "" && utils.FileExists(sshIdentifyFile) {
|
||||
clientConfig, _ = auth.PrivateKey(sshUser, sshIdentifyFile, ssh.InsecureIgnoreHostKey())
|
||||
|
||||
} else {
|
||||
if sshPassword == "" {
|
||||
return errors.New("SSH_PASSWORD environment variable is required if SSH_IDENTIFY_FILE is empty\n")
|
||||
}
|
||||
utils.Warn("Accessing the remote server using password, password is not recommended")
|
||||
clientConfig, _ = auth.PasswordKey(sshUser, sshPassword, ssh.InsecureIgnoreHostKey())
|
||||
|
||||
}
|
||||
// Create a new SCP client
|
||||
client := scp.NewClient(fmt.Sprintf("%s:%s", sshHostName, sshPort), &clientConfig)
|
||||
|
||||
// Connect to the remote server
|
||||
err = client.Connect()
|
||||
if err != nil {
|
||||
return errors.New("Couldn't establish a connection to the remote server\n")
|
||||
}
|
||||
// Close client connection after the file has been copied
|
||||
defer client.Close()
|
||||
file, err := os.OpenFile(filepath.Join(tmpPath, fileName), os.O_RDWR|os.O_CREATE, 0777)
|
||||
if err != nil {
|
||||
fmt.Println("Couldn't open the output file")
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// the context can be adjusted to provide time-outs or inherit from other contexts if this is embedded in a larger application.
|
||||
err = client.CopyFromRemote(context.Background(), file, filepath.Join(remotePath, fileName))
|
||||
|
||||
if err != nil {
|
||||
fmt.Println("Error while copying file ", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
// Package pkg /
|
||||
/*****
|
||||
@author Jonas Kaninda
|
||||
@license MIT License <https://opensource.org/licenses/MIT>
|
||||
@Copyright © 2024 Jonas Kaninda
|
||||
**/
|
||||
package pkg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/jkaninda/mysql-bkup/utils"
|
||||
"os"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
func CreateCrontabScript(disableCompression bool, storage string) {
|
||||
//task := "/usr/local/bin/backup_cron.sh"
|
||||
touchCmd := exec.Command("touch", backupCronFile)
|
||||
if err := touchCmd.Run(); err != nil {
|
||||
utils.Fatal("Error creating file %s: %v\n", backupCronFile, err)
|
||||
}
|
||||
var disableC = ""
|
||||
if disableCompression {
|
||||
disableC = "--disable-compression"
|
||||
}
|
||||
|
||||
scriptContent := fmt.Sprintf(`#!/usr/bin/env bash
|
||||
set -e
|
||||
/usr/local/bin/mysql-bkup backup --dbname %s --storage %s %v
|
||||
`, os.Getenv("DB_NAME"), storage, disableC)
|
||||
|
||||
if err := utils.WriteToFile(backupCronFile, scriptContent); err != nil {
|
||||
utils.Fatal("Error writing to %s: %v\n", backupCronFile, err)
|
||||
}
|
||||
|
||||
chmodCmd := exec.Command("chmod", "+x", "/usr/local/bin/backup_cron.sh")
|
||||
if err := chmodCmd.Run(); err != nil {
|
||||
utils.Fatal("Error changing permissions of %s: %v\n", backupCronFile, err)
|
||||
}
|
||||
|
||||
lnCmd := exec.Command("ln", "-s", "/usr/local/bin/backup_cron.sh", "/usr/local/bin/backup_cron")
|
||||
if err := lnCmd.Run(); err != nil {
|
||||
utils.Fatal("Error creating symbolic link: %v\n", err)
|
||||
|
||||
}
|
||||
|
||||
touchLogCmd := exec.Command("touch", cronLogFile)
|
||||
if err := touchLogCmd.Run(); err != nil {
|
||||
utils.Fatal("Error creating file %s: %v\n", cronLogFile, err)
|
||||
}
|
||||
|
||||
cronJob := "/etc/cron.d/backup_cron"
|
||||
touchCronCmd := exec.Command("touch", cronJob)
|
||||
if err := touchCronCmd.Run(); err != nil {
|
||||
utils.Fatal("Error creating file %s: %v\n", cronJob, err)
|
||||
}
|
||||
|
||||
cronContent := fmt.Sprintf(`%s root exec /bin/bash -c ". /run/supervisord.env; /usr/local/bin/backup_cron.sh >> %s"
|
||||
`, os.Getenv("BACKUP_CRON_EXPRESSION"), cronLogFile)
|
||||
|
||||
if err := utils.WriteToFile(cronJob, cronContent); err != nil {
|
||||
utils.Fatal("Error writing to %s: %v\n", cronJob, err)
|
||||
}
|
||||
utils.ChangePermission("/etc/cron.d/backup_cron", 0644)
|
||||
|
||||
crontabCmd := exec.Command("crontab", "/etc/cron.d/backup_cron")
|
||||
if err := crontabCmd.Run(); err != nil {
|
||||
utils.Fatal("Error updating crontab: ", err)
|
||||
}
|
||||
utils.Info("Backup job created.")
|
||||
}
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
// Package pkg /
|
||||
/*****
|
||||
@author Jonas Kaninda
|
||||
@license MIT License <https://opensource.org/licenses/MIT>
|
||||
@Copyright © 2024 Jonas Kaninda
|
||||
**/
|
||||
package pkg
|
||||
|
||||
const cronLogFile = "/var/log/mysql-bkup.log"
|
||||
const tmpPath = "/tmp/backup"
|
||||
const backupCronFile = "/usr/local/bin/backup_cron.sh"
|
||||
const algorithm = "aes256"
|
||||
const gpgHome = "gnupg"
|
||||
const gpgExtension = "gpg"
|
||||
|
||||
var (
|
||||
storage = "local"
|
||||
file = ""
|
||||
executionMode = "default"
|
||||
storagePath = "/backup"
|
||||
disableCompression = false
|
||||
encryption = false
|
||||
)
|
||||
|
||||
// dbHVars Required environment variables for database
|
||||
var dbHVars = []string{
|
||||
"DB_HOST",
|
||||
"DB_PASSWORD",
|
||||
"DB_USERNAME",
|
||||
"DB_NAME",
|
||||
}
|
||||
var tdbRVars = []string{
|
||||
"TARGET_DB_HOST",
|
||||
"TARGET_DB_PORT",
|
||||
"TARGET_DB_NAME",
|
||||
"TARGET_DB_USERNAME",
|
||||
"TARGET_DB_PASSWORD",
|
||||
}
|
||||
|
||||
var dbConf *dbConfig
|
||||
var targetDbConf *targetDbConfig
|
||||
|
||||
// sshHVars Required environment variables for SSH remote server storage
|
||||
var sshHVars = []string{
|
||||
"SSH_USER",
|
||||
"SSH_REMOTE_PATH",
|
||||
"SSH_HOST_NAME",
|
||||
"SSH_PORT",
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
// Package utils /
|
||||
/*****
|
||||
@author Jonas Kaninda
|
||||
@license MIT License <https://opensource.org/licenses/MIT>
|
||||
@Copyright © 2024 Jonas Kaninda
|
||||
**/
|
||||
package utils
|
||||
|
||||
const RestoreExample = "mysql-bkup restore --dbname database --file db_20231219_022941.sql.gz\n" +
|
||||
"bkup restore --dbname database --storage s3 --path /custom-path --file db_20231219_022941.sql.gz"
|
||||
const BackupExample = "mysql-bkup backup --dbname database --disable-compression\n" +
|
||||
"mysql-bkup backup --dbname database --storage s3 --path /custom-path --disable-compression"
|
||||
|
||||
const MainExample = "mysql-bkup backup --dbname database --disable-compression\n" +
|
||||
"mysql-bkup backup --dbname database --storage s3 --path /custom-path\n" +
|
||||
"mysql-bkup restore --dbname database --file db_20231219_022941.sql.gz"
|
||||
@@ -1,67 +0,0 @@
|
||||
// Package utils /
|
||||
/*****
|
||||
@author Jonas Kaninda
|
||||
@license MIT License <https://opensource.org/licenses/MIT>
|
||||
@Copyright © 2024 Jonas Kaninda
|
||||
**/
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
var currentTime = time.Now().Format("2006/01/02 15:04:05")
|
||||
|
||||
func Info(msg string, args ...any) {
|
||||
formattedMessage := fmt.Sprintf(msg, args...)
|
||||
if len(args) == 0 {
|
||||
fmt.Printf("%s INFO: %s\n", currentTime, msg)
|
||||
} else {
|
||||
fmt.Printf("%s INFO: %s\n", currentTime, formattedMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// Warn warning message
|
||||
func Warn(msg string, args ...any) {
|
||||
formattedMessage := fmt.Sprintf(msg, args...)
|
||||
if len(args) == 0 {
|
||||
fmt.Printf("%s WARN: %s\n", currentTime, msg)
|
||||
} else {
|
||||
fmt.Printf("%s WARN: %s\n", currentTime, formattedMessage)
|
||||
}
|
||||
}
|
||||
func Error(msg string, args ...any) {
|
||||
formattedMessage := fmt.Sprintf(msg, args...)
|
||||
if len(args) == 0 {
|
||||
fmt.Printf("%s ERROR: %s\n", currentTime, msg)
|
||||
} else {
|
||||
fmt.Printf("%s ERROR: %s\n", currentTime, formattedMessage)
|
||||
}
|
||||
}
|
||||
func Done(msg string, args ...any) {
|
||||
formattedMessage := fmt.Sprintf(msg, args...)
|
||||
if len(args) == 0 {
|
||||
fmt.Printf("%s INFO: %s\n", currentTime, msg)
|
||||
} else {
|
||||
fmt.Printf("%s INFO: %s\n", currentTime, formattedMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// Fatal logs an error message and exits the program
|
||||
func Fatal(msg string, args ...any) {
|
||||
// Fatal logs an error message and exits the program.
|
||||
formattedMessage := fmt.Sprintf(msg, args...)
|
||||
if len(args) == 0 {
|
||||
fmt.Printf("%s ERROR: %s\n", currentTime, msg)
|
||||
NotifyError(msg)
|
||||
} else {
|
||||
fmt.Printf("%s ERROR: %s\n", currentTime, formattedMessage)
|
||||
NotifyError(formattedMessage)
|
||||
|
||||
}
|
||||
|
||||
os.Exit(1)
|
||||
os.Kill.Signal()
|
||||
}
|
||||
-175
@@ -1,175 +0,0 @@
|
||||
// Package utils /
|
||||
/*****
|
||||
@author Jonas Kaninda
|
||||
@license MIT License <https://opensource.org/licenses/MIT>
|
||||
@Copyright © 2024 Jonas Kaninda
|
||||
**/
|
||||
package utils
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/credentials"
|
||||
"github.com/aws/aws-sdk-go/aws/session"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/aws/aws-sdk-go/service/s3/s3manager"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CreateSession creates a new AWS session
|
||||
func CreateSession() (*session.Session, error) {
|
||||
// AwsVars Required environment variables for AWS S3 storage
|
||||
var awsVars = []string{
|
||||
"AWS_S3_ENDPOINT",
|
||||
"AWS_S3_BUCKET_NAME",
|
||||
"AWS_ACCESS_KEY",
|
||||
"AWS_SECRET_KEY",
|
||||
"AWS_REGION",
|
||||
"AWS_REGION",
|
||||
"AWS_REGION",
|
||||
}
|
||||
|
||||
endPoint := GetEnvVariable("AWS_S3_ENDPOINT", "S3_ENDPOINT")
|
||||
accessKey := GetEnvVariable("AWS_ACCESS_KEY", "ACCESS_KEY")
|
||||
secretKey := GetEnvVariable("AWS_SECRET_KEY", "SECRET_KEY")
|
||||
_ = GetEnvVariable("AWS_S3_BUCKET_NAME", "BUCKET_NAME")
|
||||
|
||||
region := os.Getenv("AWS_REGION")
|
||||
awsDisableSsl, err := strconv.ParseBool(os.Getenv("AWS_DISABLE_SSL"))
|
||||
if err != nil {
|
||||
Fatal("Unable to parse AWS_DISABLE_SSL env var: %s", err)
|
||||
}
|
||||
|
||||
err = CheckEnvVars(awsVars)
|
||||
if err != nil {
|
||||
Fatal("Error checking environment variables\n: %s", err)
|
||||
}
|
||||
// S3 Config
|
||||
s3Config := &aws.Config{
|
||||
Credentials: credentials.NewStaticCredentials(accessKey, secretKey, ""),
|
||||
Endpoint: aws.String(endPoint),
|
||||
Region: aws.String(region),
|
||||
DisableSSL: aws.Bool(awsDisableSsl),
|
||||
S3ForcePathStyle: aws.Bool(true),
|
||||
}
|
||||
return session.NewSession(s3Config)
|
||||
|
||||
}
|
||||
|
||||
// UploadFileToS3 uploads a file to S3 with a given prefix
|
||||
func UploadFileToS3(filePath, key, bucket, prefix string) error {
|
||||
sess, err := CreateSession()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
svc := s3.New(sess)
|
||||
|
||||
file, err := os.Open(filepath.Join(filePath, key))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
fileInfo, err := file.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
objectKey := filepath.Join(prefix, key)
|
||||
|
||||
buffer := make([]byte, fileInfo.Size())
|
||||
file.Read(buffer)
|
||||
fileBytes := bytes.NewReader(buffer)
|
||||
fileType := http.DetectContentType(buffer)
|
||||
|
||||
_, err = svc.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(objectKey),
|
||||
Body: fileBytes,
|
||||
ContentLength: aws.Int64(fileInfo.Size()),
|
||||
ContentType: aws.String(fileType),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
func DownloadFile(destinationPath, key, bucket, prefix string) error {
|
||||
|
||||
sess, err := CreateSession()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
Info("Download backup from S3 storage...")
|
||||
file, err := os.Create(filepath.Join(destinationPath, key))
|
||||
if err != nil {
|
||||
fmt.Println("Failed to create file", err)
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
objectKey := filepath.Join(prefix, key)
|
||||
|
||||
downloader := s3manager.NewDownloader(sess)
|
||||
numBytes, err := downloader.Download(file,
|
||||
&s3.GetObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Println("Failed to download file", err)
|
||||
return err
|
||||
}
|
||||
Info("Backup downloaded: %s bytes size %s ", file.Name(), numBytes)
|
||||
|
||||
return nil
|
||||
}
|
||||
func DeleteOldBackup(bucket, prefix string, retention int) error {
|
||||
sess, err := CreateSession()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
svc := s3.New(sess)
|
||||
|
||||
// Get the current time and the time threshold for 7 days ago
|
||||
now := time.Now()
|
||||
backupRetentionDays := now.AddDate(0, 0, -retention)
|
||||
|
||||
// List objects in the bucket
|
||||
listObjectsInput := &s3.ListObjectsV2Input{
|
||||
Bucket: aws.String(bucket),
|
||||
Prefix: aws.String(prefix),
|
||||
}
|
||||
err = svc.ListObjectsV2Pages(listObjectsInput, func(page *s3.ListObjectsV2Output, lastPage bool) bool {
|
||||
for _, object := range page.Contents {
|
||||
if object.LastModified.Before(backupRetentionDays) {
|
||||
// Object is older than retention days, delete it
|
||||
_, err := svc.DeleteObject(&s3.DeleteObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: object.Key,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Failed to delete object %s: %v", *object.Key, err)
|
||||
} else {
|
||||
fmt.Printf("Deleted object %s\n", *object.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return !lastPage
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to list objects: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("Finished deleting old files.")
|
||||
return nil
|
||||
}
|
||||
-252
@@ -1,252 +0,0 @@
|
||||
// Package utils /
|
||||
/*****
|
||||
@author Jonas Kaninda
|
||||
@license MIT License <https://opensource.org/licenses/MIT>
|
||||
@Copyright © 2024 Jonas Kaninda
|
||||
**/
|
||||
package utils
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/spf13/cobra"
|
||||
"io"
|
||||
"io/fs"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func FileExists(filename string) bool {
|
||||
info, err := os.Stat(filename)
|
||||
if os.IsNotExist(err) {
|
||||
return false
|
||||
}
|
||||
return !info.IsDir()
|
||||
}
|
||||
|
||||
func WriteToFile(filePath, content string) error {
|
||||
file, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = file.WriteString(content)
|
||||
return err
|
||||
}
|
||||
func DeleteFile(filePath string) error {
|
||||
err := os.Remove(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete file: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func CopyFile(src, dst string) error {
|
||||
// Open the source file for reading
|
||||
sourceFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open source file: %v", err)
|
||||
}
|
||||
defer sourceFile.Close()
|
||||
|
||||
// Create the destination file
|
||||
destinationFile, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create destination file: %v", err)
|
||||
}
|
||||
defer destinationFile.Close()
|
||||
|
||||
// Copy the content from source to destination
|
||||
_, err = io.Copy(destinationFile, sourceFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to copy file: %v", err)
|
||||
}
|
||||
|
||||
// Flush the buffer to ensure all data is written
|
||||
err = destinationFile.Sync()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sync destination file: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
func ChangePermission(filePath string, mod int) {
|
||||
if err := os.Chmod(filePath, fs.FileMode(mod)); err != nil {
|
||||
Fatal("Error changing permissions of %s: %v\n", filePath, err)
|
||||
}
|
||||
|
||||
}
|
||||
func IsDirEmpty(name string) (bool, error) {
|
||||
f, err := os.Open(name)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
_, err = f.Readdirnames(1)
|
||||
if err == nil {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func GetEnv(cmd *cobra.Command, flagName, envName string) string {
|
||||
value, _ := cmd.Flags().GetString(flagName)
|
||||
if value != "" {
|
||||
err := os.Setenv(envName, value)
|
||||
if err != nil {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return os.Getenv(envName)
|
||||
}
|
||||
func FlagGetString(cmd *cobra.Command, flagName string) string {
|
||||
value, _ := cmd.Flags().GetString(flagName)
|
||||
if value != "" {
|
||||
return value
|
||||
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func FlagGetBool(cmd *cobra.Command, flagName string) bool {
|
||||
value, _ := cmd.Flags().GetBool(flagName)
|
||||
return value
|
||||
}
|
||||
|
||||
func SetEnv(key, value string) {
|
||||
|
||||
err := os.Setenv(key, value)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
func GetEnvVariable(envName, oldEnvName string) string {
|
||||
value := os.Getenv(envName)
|
||||
if value == "" {
|
||||
value = os.Getenv(oldEnvName)
|
||||
if value != "" {
|
||||
err := os.Setenv(envName, value)
|
||||
if err != nil {
|
||||
return value
|
||||
}
|
||||
Warn("%s is deprecated, please use %s instead!", oldEnvName, envName)
|
||||
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
func ShowHistory() {
|
||||
}
|
||||
|
||||
// CheckEnvVars checks if all the specified environment variables are set
|
||||
func CheckEnvVars(vars []string) error {
|
||||
missingVars := []string{}
|
||||
|
||||
for _, v := range vars {
|
||||
if os.Getenv(v) == "" {
|
||||
missingVars = append(missingVars, v)
|
||||
}
|
||||
}
|
||||
|
||||
if len(missingVars) > 0 {
|
||||
return fmt.Errorf("missing environment variables: %v", missingVars)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MakeDir create directory
|
||||
func MakeDir(dirPath string) error {
|
||||
err := os.Mkdir(dirPath, 0700)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MakeDirAll create directory
|
||||
func MakeDirAll(dirPath string) error {
|
||||
err := os.MkdirAll(dirPath, 0700)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func GetIntEnv(envName string) int {
|
||||
val := os.Getenv(envName)
|
||||
if val == "" {
|
||||
return 0
|
||||
}
|
||||
ret, err := strconv.Atoi(val)
|
||||
if err != nil {
|
||||
Error("Error: %v", err)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
func sendMessage(msg string) {
|
||||
|
||||
Info("Sending notification... ")
|
||||
chatId := os.Getenv("TG_CHAT_ID")
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"chat_id": chatId,
|
||||
"text": msg,
|
||||
})
|
||||
url := fmt.Sprintf("%s/sendMessage", getTgUrl())
|
||||
// Create an HTTP post request
|
||||
request, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request.Header.Add("Content-Type", "application/json")
|
||||
client := &http.Client{}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
code := response.StatusCode
|
||||
if code == 200 {
|
||||
Info("Notification has been sent")
|
||||
} else {
|
||||
body, _ := ioutil.ReadAll(response.Body)
|
||||
Error("Message not sent, error: %s", string(body))
|
||||
}
|
||||
|
||||
}
|
||||
func NotifySuccess(fileName string) {
|
||||
var vars = []string{
|
||||
"TG_TOKEN",
|
||||
"TG_CHAT_ID",
|
||||
}
|
||||
|
||||
//Telegram notification
|
||||
err := CheckEnvVars(vars)
|
||||
if err == nil {
|
||||
message := "MySQL Backup \n" +
|
||||
"Database has been backed up \n" +
|
||||
"Backup name is " + fileName
|
||||
sendMessage(message)
|
||||
}
|
||||
}
|
||||
func NotifyError(error string) {
|
||||
var vars = []string{
|
||||
"TG_TOKEN",
|
||||
"TG_CHAT_ID",
|
||||
}
|
||||
|
||||
//Telegram notification
|
||||
err := CheckEnvVars(vars)
|
||||
if err == nil {
|
||||
message := "MySQL Backup \n" +
|
||||
"An error occurred during database backup \n" +
|
||||
"Error: " + error
|
||||
sendMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
func getTgUrl() string {
|
||||
return fmt.Sprintf("https://api.telegram.org/bot%s", os.Getenv("TG_TOKEN"))
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user