Skip to content

Commit 03eca7a

Browse files
committed
sqlBackup: v0.1
2 parents 004a328 + 587dfcf commit 03eca7a

10 files changed

Lines changed: 154 additions & 104 deletions

File tree

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,4 +171,4 @@ cython_debug/
171171
.pypirc
172172

173173
.vscode
174-
config.ini
174+
config.ini

README.md

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@
2828
- **Remote Uploads:**
2929
Optionally upload backups to a remote server using SFTP, FTP, or SCP with configurable scheduling (daily, first day, last day, specific weekday, or a numeric day of the month).
3030

31+
- **Wildcard Support for Ignored Databases:**
32+
Use wildcard patterns (e.g., `projekti_*`) in `ignored_databases` to skip databases by name pattern.
33+
3134
- **Modular & Maintainable:**
3235
Code is organized into multiple modules (configuration, backup logic, notifications, remote upload) for easier maintenance and extensibility.
3336

@@ -49,14 +52,16 @@
4952

5053
### Prerequisites
5154

52-
- **Python 3.6+** is required.
53-
- Install the necessary Python packages using pip:
54-
```bash
55-
pip install requests paramiko twilio
56-
```
57-
- `requests`: For HTTP requests (used in notifications and remote uploads).
58-
- `paramiko`: For SFTP uploads.
59-
- `twilio`: For sending SMS notifications.
55+
1. **Python 3.6+** is required.
56+
2. **MySQL or MariaDB** client tools installed (e.g., `mysql`, `mysqldump`).
57+
3. Install the necessary Python packages using `pip`:
58+
```bash
59+
pip install requests paramiko twilio fnmatch
60+
```
61+
- `requests`: For HTTP requests (used in notifications and remote uploads).
62+
- `paramiko`: For SFTP uploads.
63+
- `twilio`: For sending SMS notifications.
64+
- `fnmatch`: For wildcard support
6065

6166
### Setup
6267

@@ -80,13 +85,21 @@
8085
└── remote_upload.py # Remote upload functionality
8186
```
8287

83-
3. **Configure the Project:**
84-
- **Important:** Do not modify `config.ini.default` directly. Instead, copy it to create your local configuration file:
88+
3. **Configuration File Setup:**
89+
- **Important:** Do not modify `config.ini.default` directly. Instead, copy it:
8590
```bash
8691
cp config.ini.default config.ini
8792
```
8893
- Open `config.ini` and adjust the settings to match your environment (e.g., MySQL credentials, notification channel settings, remote upload settings, etc.).
8994

95+
> **Note:** If you pull new changes from this repo in the future, your local `config.ini` will remain untouched, preserving your production settings.
96+
97+
4. **(Optional) Unit Tests:**
98+
- If you want to run the included unit tests (if any), install `unittest` (bundled with Python), plus any additional test dependencies, and run:
99+
```bash
100+
python3 -m unittest discover
101+
```
102+
90103
## Configuration Tutorial
91104

92105
The `config.ini` file is the central configuration file for **sqlBackup**. It is divided into several sections:
@@ -101,6 +114,7 @@ The `config.ini` file is the central configuration file for **sqlBackup**. It is
101114
- **mysql_path:** Path to the MySQL client.
102115
- **mysqldump_path:** Path to the mysqldump utility.
103116
- **ignored_databases:** Comma-separated list of databases to skip.
117+
- **Now supports wildcards:** e.g. `sys, mysql, projekti_*`. Any database name matching `projekti_*` will be ignored (e.g., `projekti_alpha`, `projekti_1`).
104118

105119
### [telegram]
106120
- **enabled:** Enable or disable Telegram notifications.
@@ -142,7 +156,7 @@ The `config.ini` file is the central configuration file for **sqlBackup**. It is
142156
### [export]
143157
- **include_routines:** Include stored procedures and functions.
144158
- **include_events:** Include scheduled events.
145-
- **column_statistics:** If set to false, the script will add `--column-statistics=0` to the dump command.
159+
- **column_statistics:** If set to false, the script adds `--column-statistics=0` to the dump command (helpful for older servers).
146160

147161
### [remote]
148162
- **upload_enabled:** Enable or disable remote upload of backups.
@@ -162,7 +176,7 @@ python3 main.py
162176
```
163177

164178
The script will:
165-
- Connect to MySQL and dump databases (skipping those in `ignored_databases`).
179+
- Connect to MySQL and dump databases (skipping those in `ignored_databases`, including wildcards).
166180
- Archive each dump according to the specified format.
167181
- Display a summary table with database name, backup status, elapsed time, dump size, and archive size.
168182
- Send notifications via the enabled channels.

config.ini

Lines changed: 0 additions & 82 deletions
This file was deleted.

sqlBackup

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ from src.remote_upload import upload_backups
1313

1414
def main():
1515
config = load_config()
16-
16+
1717
# Run backups and get summary.
1818
errors, summary = run_backups(config)
1919
if errors:

src/backup.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import time
88
import threading
99
from itertools import cycle
10+
from fnmatch import fnmatch
1011

1112
from .config import CONFIG
1213

@@ -20,7 +21,8 @@
2021
# --- Configuration Variables ---
2122
BACKUP_DIR = CONFIG.get("backup", "backup_dir")
2223
ARCHIVE_FORMAT = CONFIG.get("backup", "archive_format").lower()
23-
IGNORED_DATABASES = set(x.strip() for x in CONFIG.get("mysql", "ignored_databases").split(','))
24+
# Split, then strip whitespace from each pattern
25+
IGNORED_DB_PATTERNS = [p.strip() for p in CONFIG.get("mysql", "ignored_databases").split(",")]
2426
MYSQL = CONFIG.get("mysql", "mysql_path")
2527
MYSQLDUMP = CONFIG.get("mysql", "mysqldump_path")
2628
TIMESTAMP = datetime.datetime.now().strftime("%F")
@@ -63,7 +65,7 @@ def format_size(size: int) -> str:
6365
return f"{size / (1024 * 1024 * 1024):.1f} GB"
6466

6567
def create_temp_mysql_config() -> str:
66-
"""Create a temporary MySQL client config file from the [mysql] section."""
68+
from .config import CONFIG
6769
tmp = tempfile.NamedTemporaryFile(mode="w", delete=False)
6870
tmp.write("[client]\n")
6971
tmp.write(f"user = {CONFIG.get('mysql', 'user')}\n")
@@ -104,6 +106,16 @@ def get_all_databases() -> list:
104106
print(f"{RED}Error retrieving databases: {e.stderr}{RESET}")
105107
sys.exit(1)
106108

109+
def is_ignored(db_name: str) -> bool:
110+
"""
111+
Return True if db_name matches any of the patterns in IGNORED_DB_PATTERNS.
112+
We use fnmatch to support wildcards (like projekti_*).
113+
"""
114+
for pattern in IGNORED_DB_PATTERNS:
115+
if fnmatch(db_name, pattern):
116+
return True
117+
return False
118+
107119
def backup_database(db: str) -> tuple:
108120
"""
109121
Dump the given database, archive it, and return a tuple:
@@ -248,18 +260,22 @@ def run_backups(config) -> tuple:
248260
databases = get_all_databases()
249261
errors = []
250262
summary_lines = []
263+
251264
print_table_header()
252265
for db in databases:
253-
if db in IGNORED_DATABASES:
266+
if is_ignored(db):
254267
print_table_row(db, "Skipped", "-", "-", "-")
255268
continue
269+
256270
start = time.time()
257271
status, dump_size, archive_size = backup_database(db)
258272
elapsed = f"{time.time() - start:.1f}"
259273
if status == "Error":
260274
errors.append(db)
275+
261276
print_table_row(db, status, elapsed, dump_size, archive_size)
262277
summary_lines.append(f"{db}: {status} in {elapsed}s")
278+
263279
separator = f"|{'-'*27}|{'-'*17}|{'-'*12}|{'-'*14}|{'-'*16}|"
264280
print(separator)
265281
summary = "\n".join(summary_lines)

src/notifications.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,19 +48,20 @@ def send_email_notification(config, message: str) -> None:
4848
except Exception as e:
4949
print(f"{RED}Email notification failed: {e}{RESET}")
5050

51-
def send_slack_notification(config, message: str) -> None:
51+
def send_slack_notification(config, message):
5252
if config.has_section("slack") and config.getboolean("slack", "enabled", fallback=False):
5353
webhook_url = config.get("slack", "webhook_url")
5454
try:
5555
payload = {"text": message}
56+
# Note: use json=payload, not data=payload
5657
response = requests.post(webhook_url, json=payload)
5758
if response.status_code != 200:
58-
print(f"{RED}Slack notification failed: {response.text}{RESET}")
59+
print(f"Slack notification failed: {response.text}")
5960
else:
60-
print(f"{BLUE}Slack notification sent.{RESET}")
61+
print("Slack notification sent.")
6162
except Exception as e:
62-
print(f"{RED}Slack notification error: {e}{RESET}")
63-
63+
print(f"Slack notification error: {e}")
64+
6465
def send_sms_notification(config, message: str) -> None:
6566
if config.has_section("sms") and config.getboolean("sms", "enabled", fallback=False):
6667
try:

tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# __init__.py (empty)

tests/test_backup.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import unittest
2+
import datetime
3+
from src.backup import format_size, should_upload
4+
5+
class TestBackupHelpers(unittest.TestCase):
6+
def test_format_size_bytes(self):
7+
self.assertEqual(format_size(500), "500 B")
8+
9+
def test_format_size_kb(self):
10+
self.assertEqual(format_size(2048), "2.0 KB")
11+
12+
def test_format_size_mb(self):
13+
self.assertEqual(format_size(1048576), "1.0 MB")
14+
15+
def test_format_size_gb(self):
16+
self.assertEqual(format_size(1073741824), "1.0 GB")
17+
18+
def test_should_upload_daily(self):
19+
self.assertTrue(should_upload("daily"))
20+
21+
def test_should_upload_numeric(self):
22+
now = datetime.datetime.now()
23+
self.assertTrue(should_upload(str(now.day)))
24+
25+
if __name__ == "__main__":
26+
unittest.main()

tests/test_config.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import unittest
2+
from src.config import load_config
3+
4+
class TestConfig(unittest.TestCase):
5+
def test_config_loaded(self):
6+
config = load_config()
7+
# Check that required sections exist.
8+
self.assertTrue(config.has_section("backup"))
9+
self.assertTrue(config.has_section("mysql"))
10+
self.assertTrue(config.has_section("notification"))
11+
12+
if __name__ == "__main__":
13+
unittest.main()

tests/test_notification.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import unittest
2+
from unittest.mock import patch, MagicMock
3+
from src.notifications import send_telegram_notification, send_email_notification, send_slack_notification
4+
from src.config import CONFIG
5+
6+
class TestNotifications(unittest.TestCase):
7+
@patch("src.notifications.requests.post")
8+
def test_send_telegram_notification(self, mock_post):
9+
# Prepare a dummy config with telegram enabled
10+
dummy_config = CONFIG
11+
dummy_config["telegram"] = {
12+
"enabled": "true",
13+
"telegram_token": "dummy_token",
14+
"telegram_chatid": "dummy_chat"
15+
}
16+
send_telegram_notification(dummy_config, "Test Telegram message")
17+
self.assertTrue(mock_post.called, "requests.post was not called for Telegram")
18+
19+
@patch("src.notifications.smtplib.SMTP")
20+
def test_send_email_notification(self, mock_smtp):
21+
dummy_config = CONFIG
22+
dummy_config["email"] = {
23+
"enabled": "true",
24+
"smtp_server": "smtp.example.com",
25+
"smtp_port": "587",
26+
"username": "user@example.com",
27+
"password": "secret",
28+
"from_address": "from@example.com",
29+
"to_addresses": "to@example.com"
30+
}
31+
send_email_notification(dummy_config, "Test Email message")
32+
self.assertTrue(mock_smtp.called, "SMTP was not called for Email")
33+
34+
@patch("src.notifications.requests.post")
35+
def test_send_slack_notification(self, mock_post):
36+
# Configure the mock to simulate a successful Slack response
37+
mock_response = MagicMock()
38+
mock_response.status_code = 200
39+
mock_response.text = "OK"
40+
mock_post.return_value = mock_response
41+
42+
dummy_config = CONFIG
43+
dummy_config["slack"] = {
44+
"enabled": "true",
45+
"webhook_url": "https://hooks.slack.com/services/dummy"
46+
}
47+
48+
# Call the Slack notification function
49+
send_slack_notification(dummy_config, "Test Slack message")
50+
51+
# Ensure requests.post was actually called
52+
self.assertTrue(mock_post.called, "requests.post was not called for Slack")
53+
54+
# Verify we call requests.post with the correct URL and JSON payload
55+
mock_post.assert_called_once_with(
56+
"https://hooks.slack.com/services/dummy",
57+
json={"text": "Test Slack message"}
58+
)
59+
60+
if __name__ == "__main__":
61+
unittest.main()

0 commit comments

Comments
 (0)