Compare commits
34 Commits
c5a1ac0551
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 4337b0e925 | |||
| 1cb731cbdb | |||
| 38a0d788d4 | |||
| 12f35934a9 | |||
| e6ee91babf | |||
| f12d89f3da | |||
| d130ba2a11 | |||
| 2533b56549 | |||
| 68ff4238e0 | |||
| debc753f13 | |||
| c586bd1817 | |||
| e823f65c3c | |||
| 5c627f3a64 | |||
| 7b6ce02a93 | |||
| e7d3e1140c | |||
| 6fe3eebf36 | |||
| 79d15628bd | |||
| 3a4f3267eb | |||
| 8761a04c40 | |||
| 586991bcf1 | |||
| ddb5f3716d | |||
| b805f8387e | |||
| da50051a3f | |||
| 8ef9bbde06 | |||
| 737f9bea38 | |||
| aa8a68aa80 | |||
| 7fcc5afc64 | |||
| e7e98d0b47 | |||
| 8479c378ee | |||
| 274e1e2e59 | |||
| eb0bdaedbd | |||
| 99dc6e0abf | |||
| e8ba6df102 | |||
| ffd9bf3d39 |
+101
@@ -0,0 +1,101 @@
|
|||||||
|
# TISBackup
|
||||||
|
rpm/
|
||||||
|
deb/
|
||||||
|
.gitea/
|
||||||
|
.hadolint.yml
|
||||||
|
.pre-commit-config.yaml
|
||||||
|
README.md
|
||||||
|
compose.yml
|
||||||
|
docs/
|
||||||
|
docs-sphinx-rst/
|
||||||
|
samples/
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
|
||||||
|
|
||||||
|
# CI
|
||||||
|
.codeclimate.yml
|
||||||
|
.travis.yml
|
||||||
|
.taskcluster.yml
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker-compose.yml
|
||||||
|
Dockerfile
|
||||||
|
.docker
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
**/__pycache__/
|
||||||
|
**/*.py[cod]
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.coverage
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Virtual environment
|
||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Python mode for VIM
|
||||||
|
.ropeproject
|
||||||
|
**/.ropeproject
|
||||||
|
|
||||||
|
# Vim swap files
|
||||||
|
**/*.swp
|
||||||
|
|
||||||
|
# VS Code
|
||||||
|
.vscode/
|
||||||
@@ -14,12 +14,11 @@ jobs:
|
|||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: '3.14'
|
||||||
cache: 'pip' # caching pip dependencies
|
cache: 'pip' # caching pip dependencies
|
||||||
- run: pip install ruff
|
- run: pip install ruff
|
||||||
- run: |
|
- run: |
|
||||||
ruff check .
|
ruff check .
|
||||||
ruff fix .
|
|
||||||
# - uses: stefanzweifel/git-auto-commit-action@v4
|
# - uses: stefanzweifel/git-auto-commit-action@v4
|
||||||
# with:
|
# with:
|
||||||
# commit_message: 'style fixes by ruff'
|
# commit_message: 'style fixes by ruff'
|
||||||
|
|||||||
+130
-9
@@ -1,16 +1,137 @@
|
|||||||
*.bak
|
# ===============================================
|
||||||
*.swp
|
# TISBackup .gitignore
|
||||||
*~
|
# ===============================================
|
||||||
|
|
||||||
|
# Python compiled files
|
||||||
|
# ===============================================
|
||||||
*.pyc
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
__pycache__/
|
||||||
|
*.so
|
||||||
|
*.egg
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.whl
|
||||||
|
|
||||||
|
# Python virtual environments
|
||||||
|
# ===============================================
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
.Python
|
||||||
|
|
||||||
|
# IDE and editor files
|
||||||
|
# ===============================================
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
*.sublime-project
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# Testing and coverage
|
||||||
|
# ===============================================
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
.hypothesis/
|
||||||
|
|
||||||
|
# Linting and type checking
|
||||||
|
# ===============================================
|
||||||
|
.ruff_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
.pylint.d/
|
||||||
|
|
||||||
|
# Backup and temporary files
|
||||||
|
# ===============================================
|
||||||
|
*.bak
|
||||||
|
*.backup
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
*.old
|
||||||
|
*.orig
|
||||||
|
*.log
|
||||||
|
*.log.*
|
||||||
|
|
||||||
|
# TISBackup runtime files
|
||||||
|
# ===============================================
|
||||||
|
# Task queue database
|
||||||
/tasks.sqlite
|
/tasks.sqlite
|
||||||
/tasks.sqlite-wal
|
/tasks.sqlite-wal
|
||||||
/srvinstallation
|
|
||||||
/tasks.sqlite-shm
|
/tasks.sqlite-shm
|
||||||
.idea
|
|
||||||
/deb/builddir
|
# Local configuration (samples are tracked, local overrides are not)
|
||||||
|
/tisbackup-config.ini
|
||||||
|
/tisbackup_gui.ini
|
||||||
|
|
||||||
|
# Backup data and logs (should never be in git)
|
||||||
|
/backups/
|
||||||
|
/log/
|
||||||
|
*.sqlite-journal
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
# ===============================================
|
||||||
|
/deb/builddir/
|
||||||
/deb/*.deb
|
/deb/*.deb
|
||||||
/lib
|
|
||||||
/rpm/*.rpm
|
/rpm/*.rpm
|
||||||
/rpm/RPMS
|
/rpm/RPMS/
|
||||||
/rpm/BUILD
|
/rpm/BUILD/
|
||||||
/rpm/__VERSION__
|
/rpm/__VERSION__
|
||||||
|
/srvinstallation/
|
||||||
|
|
||||||
|
# Documentation builds
|
||||||
|
# ===============================================
|
||||||
|
docs-sphinx-rst/build/
|
||||||
|
docs/_build/
|
||||||
|
site/
|
||||||
|
|
||||||
|
# Package manager files
|
||||||
|
# ===============================================
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
# ===============================================
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
|
||||||
|
# Secret and sensitive files
|
||||||
|
# ===============================================
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.cert
|
||||||
|
*.p12
|
||||||
|
*.pfx
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
secrets/
|
||||||
|
private/
|
||||||
|
|
||||||
|
# Claude Code files
|
||||||
|
# ===============================================
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# Project specific
|
||||||
|
# ===============================================
|
||||||
|
# Legacy library (should use libtisbackup instead)
|
||||||
|
/lib/
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
DL3008failure-threshold: warning
|
||||||
|
format: tty
|
||||||
|
ignored:
|
||||||
|
- DL3007
|
||||||
|
override:
|
||||||
|
error:
|
||||||
|
- DL3015
|
||||||
|
warning:
|
||||||
|
- DL3015
|
||||||
|
info:
|
||||||
|
- DL3008
|
||||||
|
style:
|
||||||
|
- DL3015
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
repos:
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v5.0.0
|
||||||
|
hooks:
|
||||||
|
- id: trailing-whitespace
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
- id: check-yaml
|
||||||
|
|
||||||
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
# Ruff version.
|
||||||
|
rev: v0.8.1
|
||||||
|
hooks:
|
||||||
|
# Run the linter.
|
||||||
|
- id: ruff
|
||||||
|
# Run the formatter.
|
||||||
|
- id: ruff-format
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.14
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"conventionalCommits.scopes": [
|
||||||
|
"tisbackup"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
# TISBackup Authentication System
|
||||||
|
|
||||||
|
TISBackup provides a pluggable authentication system for securing the Flask web interface. You can choose between multiple authentication methods based on your security requirements.
|
||||||
|
|
||||||
|
## Supported Authentication Methods
|
||||||
|
|
||||||
|
1. **None** - No authentication (default, NOT recommended for production)
|
||||||
|
2. **Basic Auth** - HTTP Basic Authentication with username/password
|
||||||
|
3. **Flask-Login** - Session-based authentication with username/password
|
||||||
|
4. **OAuth2** - OAuth authentication (Google, GitHub, GitLab, or generic provider)
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Choose Authentication Method
|
||||||
|
|
||||||
|
Add an `[authentication]` section to `/etc/tis/tisbackup_gui.ini`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[authentication]
|
||||||
|
type = basic
|
||||||
|
username = admin
|
||||||
|
password = $2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYWv.5qVQK6
|
||||||
|
use_bcrypt = True
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Restart TISBackup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose restart tisbackup_gui
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Guide
|
||||||
|
|
||||||
|
### Basic Authentication
|
||||||
|
|
||||||
|
Simple HTTP Basic Auth with username and password.
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Easy to set up
|
||||||
|
- Works with all HTTP clients
|
||||||
|
- No session management needed
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- Credentials sent with every request
|
||||||
|
- No logout functionality
|
||||||
|
- Browser password prompt can be confusing
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[authentication]
|
||||||
|
type = basic
|
||||||
|
username = admin
|
||||||
|
# Use bcrypt hash (recommended)
|
||||||
|
password = $2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYWv.5qVQK6
|
||||||
|
use_bcrypt = True
|
||||||
|
realm = TISBackup Admin
|
||||||
|
```
|
||||||
|
|
||||||
|
**Generate bcrypt hash:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -c "import bcrypt; print(bcrypt.hashpw(b'yourpassword', bcrypt.gensalt()).decode())"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Docker environment:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
tisbackup_gui:
|
||||||
|
environment:
|
||||||
|
- TISBACKUP_SECRET_KEY=your-secret-key
|
||||||
|
# Optional: Pass credentials via env vars
|
||||||
|
# Then reference in config with ${AUTH_PASSWORD}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Flask-Login Authentication
|
||||||
|
|
||||||
|
Session-based authentication with login page and user management.
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Clean login/logout workflow
|
||||||
|
- Session-based (no credentials in each request)
|
||||||
|
- Multiple users supported
|
||||||
|
- Password hashing with bcrypt
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- Requires custom login page
|
||||||
|
- Session management overhead
|
||||||
|
- Cookies must be enabled
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[authentication]
|
||||||
|
type = flask-login
|
||||||
|
users_file = /etc/tis/users.txt
|
||||||
|
use_bcrypt = True
|
||||||
|
login_view = login
|
||||||
|
```
|
||||||
|
|
||||||
|
**Create users file** (`/etc/tis/users.txt`):
|
||||||
|
|
||||||
|
```
|
||||||
|
admin:$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYWv.5qVQK6
|
||||||
|
operator:$2b$12$abcdefghijklmnopqrstuvwxyz1234567890ABCDEFGHIJKLMNO
|
||||||
|
viewer:$2b$12$ANOTHERBCRYPTHASHHERE1234567890ABCDEFGHIJKLMNOPQRS
|
||||||
|
```
|
||||||
|
|
||||||
|
**Generate user entry:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
USERNAME="admin"
|
||||||
|
PASSWORD="yourpassword"
|
||||||
|
HASH=$(python3 -c "import bcrypt; print(bcrypt.hashpw(b'$PASSWORD', bcrypt.gensalt()).decode())")
|
||||||
|
echo "$USERNAME:$HASH" >> /etc/tis/users.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**Permissions:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod 600 /etc/tis/users.txt
|
||||||
|
chown root:root /etc/tis/users.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### OAuth2 Authentication
|
||||||
|
|
||||||
|
Delegate authentication to external OAuth providers (Google, GitHub, GitLab, etc.)
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- No password management
|
||||||
|
- Leverage existing identity providers
|
||||||
|
- Support for SSO
|
||||||
|
- Can restrict by domain or specific users
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- Requires OAuth app registration
|
||||||
|
- Internet connectivity required
|
||||||
|
- More complex setup
|
||||||
|
- External dependency
|
||||||
|
|
||||||
|
#### Google OAuth
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
|
||||||
|
1. Go to [Google Cloud Console](https://console.cloud.google.com/apis/credentials)
|
||||||
|
2. Create OAuth 2.0 Client ID
|
||||||
|
3. Add authorized redirect URI: `http://your-server:8080/oauth/callback`
|
||||||
|
4. Note the Client ID and Client Secret
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[authentication]
|
||||||
|
type = oauth
|
||||||
|
provider = google
|
||||||
|
client_id = 123456789-abcdefghijklmnop.apps.googleusercontent.com
|
||||||
|
client_secret = GOCSPX-your-client-secret-here
|
||||||
|
redirect_uri = http://your-server:8080/oauth/callback
|
||||||
|
|
||||||
|
# Restrict to specific domain(s)
|
||||||
|
authorized_domains = example.com,mycompany.com
|
||||||
|
|
||||||
|
# Or restrict to specific users
|
||||||
|
authorized_users = admin@example.com,backup-admin@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GitHub OAuth
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
|
||||||
|
1. Go to GitHub Settings > Developer settings > [OAuth Apps](https://github.com/settings/developers)
|
||||||
|
2. Register a new application
|
||||||
|
3. Set Authorization callback URL: `http://your-server:8080/oauth/callback`
|
||||||
|
4. Note the Client ID and Client Secret
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[authentication]
|
||||||
|
type = oauth
|
||||||
|
provider = github
|
||||||
|
client_id = your-github-client-id
|
||||||
|
client_secret = your-github-client-secret
|
||||||
|
redirect_uri = http://your-server:8080/oauth/callback
|
||||||
|
authorized_users = admin@example.com,devops@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GitLab OAuth
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
|
||||||
|
1. Go to GitLab User Settings > [Applications](https://gitlab.com/-/profile/applications)
|
||||||
|
2. Create application with scopes: `read_user`, `email`
|
||||||
|
3. Set Redirect URI: `http://your-server:8080/oauth/callback`
|
||||||
|
4. Note the Application ID and Secret
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[authentication]
|
||||||
|
type = oauth
|
||||||
|
provider = gitlab
|
||||||
|
client_id = your-gitlab-application-id
|
||||||
|
client_secret = your-gitlab-secret
|
||||||
|
redirect_uri = http://your-server:8080/oauth/callback
|
||||||
|
authorized_domains = example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Generic OAuth Provider
|
||||||
|
|
||||||
|
For custom OAuth providers:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[authentication]
|
||||||
|
type = oauth
|
||||||
|
provider = generic
|
||||||
|
client_id = your-client-id
|
||||||
|
client_secret = your-client-secret
|
||||||
|
redirect_uri = http://your-server:8080/oauth/callback
|
||||||
|
|
||||||
|
# Custom endpoints
|
||||||
|
authorization_endpoint = https://auth.example.com/oauth/authorize
|
||||||
|
token_endpoint = https://auth.example.com/oauth/token
|
||||||
|
userinfo_endpoint = https://auth.example.com/oauth/userinfo
|
||||||
|
scopes = openid,email,profile
|
||||||
|
|
||||||
|
# Authorization rules
|
||||||
|
authorized_domains = example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Best Practices
|
||||||
|
|
||||||
|
### 1. Use HTTPS in Production
|
||||||
|
|
||||||
|
Always use a reverse proxy with TLS:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name tisbackup.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/tisbackup.example.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/tisbackup.example.com/privkey.pem;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:8080/;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Set Strong Flask Secret Key
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate secret
|
||||||
|
python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
|
||||||
|
# Set in environment
|
||||||
|
export TISBACKUP_SECRET_KEY=your-generated-secret-key
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Protect Configuration Files
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod 600 /etc/tis/tisbackup_gui.ini
|
||||||
|
chmod 600 /etc/tis/users.txt # if using Flask-Login
|
||||||
|
chown root:root /etc/tis/*.ini
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Use Environment Variables for Secrets
|
||||||
|
|
||||||
|
Instead of hardcoding secrets in config files:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[authentication]
|
||||||
|
type = oauth
|
||||||
|
client_id = ${OAUTH_CLIENT_ID}
|
||||||
|
client_secret = ${OAUTH_CLIENT_SECRET}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then set in Docker Compose:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
tisbackup_gui:
|
||||||
|
environment:
|
||||||
|
- OAUTH_CLIENT_ID=your-client-id
|
||||||
|
- OAUTH_CLIENT_SECRET=your-client-secret
|
||||||
|
- TISBACKUP_SECRET_KEY=your-secret-key
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Regularly Rotate Credentials
|
||||||
|
|
||||||
|
- Change passwords/secrets every 90 days
|
||||||
|
- Rotate OAuth client secrets annually
|
||||||
|
- Review authorized users/domains regularly
|
||||||
|
|
||||||
|
### 6. Monitor Authentication Logs
|
||||||
|
|
||||||
|
Check logs for failed authentication attempts:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker logs tisbackup_gui | grep -i "auth"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Basic Auth Not Working
|
||||||
|
|
||||||
|
1. **Verify password hash:**
|
||||||
|
```bash
|
||||||
|
python3 -c "import bcrypt; print(bcrypt.checkpw(b'yourpassword', b'$2b$12$your-hash'))"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Check browser credentials:**
|
||||||
|
- Clear browser cache
|
||||||
|
- Try incognito/private mode
|
||||||
|
|
||||||
|
### Flask-Login Issues
|
||||||
|
|
||||||
|
1. **Users file not found:**
|
||||||
|
```bash
|
||||||
|
ls -la /etc/tis/users.txt
|
||||||
|
chmod 600 /etc/tis/users.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Session problems:**
|
||||||
|
- Check `TISBACKUP_SECRET_KEY` is set
|
||||||
|
- Ensure cookies are enabled
|
||||||
|
|
||||||
|
### OAuth Problems
|
||||||
|
|
||||||
|
1. **Redirect URI mismatch:**
|
||||||
|
- Ensure redirect URI in config matches OAuth app settings exactly
|
||||||
|
- Check for http vs https mismatch
|
||||||
|
|
||||||
|
2. **Unauthorized domain/user:**
|
||||||
|
- Verify email matches `authorized_users` or domain matches `authorized_domains`
|
||||||
|
- Check OAuth provider returns email in user info
|
||||||
|
|
||||||
|
3. **Token errors:**
|
||||||
|
- Verify client ID and secret are correct
|
||||||
|
- Check OAuth app is enabled
|
||||||
|
- Ensure scopes are correct
|
||||||
|
|
||||||
|
## API Access with Authentication
|
||||||
|
|
||||||
|
### Basic Auth
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -u admin:password http://localhost:8080/api/backups
|
||||||
|
```
|
||||||
|
|
||||||
|
### OAuth (with access token)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Not recommended for API access - use service account or API keys instead
|
||||||
|
```
|
||||||
|
|
||||||
|
### Recommendation for API Access
|
||||||
|
|
||||||
|
For programmatic API access, use Basic Auth with a dedicated API user:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[authentication]
|
||||||
|
type = basic
|
||||||
|
username = api-user
|
||||||
|
password = $2b$12$...
|
||||||
|
```
|
||||||
|
|
||||||
|
Or implement API key authentication separately for API endpoints.
|
||||||
|
|
||||||
|
## Migration Guide
|
||||||
|
|
||||||
|
### From No Auth to Basic Auth
|
||||||
|
|
||||||
|
1. Add authentication section to config
|
||||||
|
2. Restart service
|
||||||
|
3. Update client scripts with credentials
|
||||||
|
|
||||||
|
### From Basic Auth to OAuth
|
||||||
|
|
||||||
|
1. Register OAuth application
|
||||||
|
2. Update configuration
|
||||||
|
3. Test OAuth login flow
|
||||||
|
4. Update redirect URI if needed
|
||||||
|
|
||||||
|
### From Flask-Login to OAuth
|
||||||
|
|
||||||
|
1. Register OAuth application
|
||||||
|
2. Map user emails to OAuth provider
|
||||||
|
3. Update configuration
|
||||||
|
4. Test migration with test users first
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues or questions:
|
||||||
|
- Check logs: `docker logs tisbackup_gui`
|
||||||
|
- Review configuration syntax
|
||||||
|
- Verify dependencies are installed
|
||||||
|
- See [SECURITY_IMPROVEMENTS.md](../SECURITY_IMPROVEMENTS.md) for security context
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
TISBackup is a server-side backup orchestration system written in Python. It executes scheduled backups of various data sources (databases, files, VMs) from remote Linux and Windows systems. The project consists of:
|
||||||
|
|
||||||
|
- A CLI tool ([tisbackup.py](tisbackup.py)) for executing backups, cleanup, and monitoring
|
||||||
|
- A Flask web GUI ([tisbackup_gui.py](tisbackup_gui.py)) for managing backups
|
||||||
|
- A pluggable backup driver architecture in [libtisbackup/](libtisbackup/)
|
||||||
|
- Task queue system using Huey with Redis ([tasks.py](tasks.py), [config.py](config.py))
|
||||||
|
- Docker-based deployment with cron scheduling
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
**IMPORTANT: Always use `uv run` to execute Python commands in this project.**
|
||||||
|
|
||||||
|
### Dependency Management
|
||||||
|
```bash
|
||||||
|
# Install dependencies (uses uv)
|
||||||
|
uv sync --locked
|
||||||
|
|
||||||
|
# Update dependencies
|
||||||
|
uv lock
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linting
|
||||||
|
```bash
|
||||||
|
# Run ruff linter (fast, primary linter)
|
||||||
|
uv run ruff check .
|
||||||
|
|
||||||
|
# Auto-fix linting issues
|
||||||
|
uv run ruff check --fix .
|
||||||
|
|
||||||
|
# Run pylint (comprehensive static analysis)
|
||||||
|
uv run pylint libtisbackup/
|
||||||
|
|
||||||
|
# Run pylint on specific file
|
||||||
|
uv run pylint libtisbackup/ssh.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
uv run pytest
|
||||||
|
|
||||||
|
# Run tests for specific module
|
||||||
|
uv run pytest tests/test_ssh.py
|
||||||
|
|
||||||
|
# Run with verbose output
|
||||||
|
uv run pytest -v
|
||||||
|
|
||||||
|
# Run tests matching a pattern
|
||||||
|
uv run pytest -k "ssh"
|
||||||
|
|
||||||
|
# Run with coverage report
|
||||||
|
uv run pytest --cov=libtisbackup --cov-report=html --cov-report=term-missing
|
||||||
|
|
||||||
|
# Run tests with coverage and show only missing lines
|
||||||
|
uv run pytest --cov=libtisbackup --cov-report=term-missing
|
||||||
|
|
||||||
|
# Generate HTML coverage report (opens in browser)
|
||||||
|
uv run pytest --cov=libtisbackup --cov-report=html
|
||||||
|
# Then open htmlcov/index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
**Coverage reports:**
|
||||||
|
- Terminal report: Shows coverage percentage with missing line numbers
|
||||||
|
- HTML report: Detailed interactive report in `htmlcov/` directory
|
||||||
|
|
||||||
|
See [tests/README.md](tests/README.md) for detailed testing documentation.
|
||||||
|
|
||||||
|
### Running the Application
|
||||||
|
|
||||||
|
**Web GUI (development):**
|
||||||
|
```bash
|
||||||
|
uv run python tisbackup_gui.py
|
||||||
|
# Runs on port 8080, requires config at /etc/tis/tisbackup_gui.ini
|
||||||
|
```
|
||||||
|
|
||||||
|
**CLI Commands:**
|
||||||
|
```bash
|
||||||
|
# Run backups
|
||||||
|
uv run python tisbackup.py -c /etc/tis/tisbackup-config.ini backup
|
||||||
|
|
||||||
|
# Run specific backup section
|
||||||
|
uv run python tisbackup.py -c /etc/tis/tisbackup-config.ini -s section_name backup
|
||||||
|
|
||||||
|
# Cleanup old backups
|
||||||
|
uv run python tisbackup.py -c /etc/tis/tisbackup-config.ini cleanup
|
||||||
|
|
||||||
|
# Check backup status (for Nagios)
|
||||||
|
uv run python tisbackup.py -c /etc/tis/tisbackup-config.ini checknagios
|
||||||
|
|
||||||
|
# List available backup drivers
|
||||||
|
uv run python tisbackup.py listdrivers
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build image
|
||||||
|
docker build . -t tisbackup:latest
|
||||||
|
|
||||||
|
# Run via docker compose (see README.md for full setup)
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Core Components
|
||||||
|
|
||||||
|
**Main Entry Points:**
|
||||||
|
- [tisbackup.py](tisbackup.py) - CLI application with argument parsing and action routing (backup, cleanup, checknagios, etc.)
|
||||||
|
- [tisbackup_gui.py](tisbackup_gui.py) - Flask web application providing UI for backup management and status monitoring
|
||||||
|
- [tasks.py](tasks.py) - Huey task definitions for async operations (export_backup)
|
||||||
|
|
||||||
|
**Backup Driver System:**
|
||||||
|
|
||||||
|
All backup logic is implemented via driver classes in [libtisbackup/drivers/](libtisbackup/drivers/):
|
||||||
|
|
||||||
|
- Base class: `backup_generic` in [base_driver.py](libtisbackup/base_driver.py) (abstract)
|
||||||
|
- Each driver inherits from `backup_generic` and implements specific backup logic
|
||||||
|
- Drivers are registered via the `register_driver()` decorator function
|
||||||
|
- Configuration is read from INI files using the `read_config()` method
|
||||||
|
- All driver implementations are in [libtisbackup/drivers/](libtisbackup/drivers/) subdirectory
|
||||||
|
|
||||||
|
**Library Modules:**
|
||||||
|
- [base_driver.py](libtisbackup/base_driver.py) - Core `backup_generic` class, driver registry, Nagios states
|
||||||
|
- [database.py](libtisbackup/database.py) - `BackupStat` class for SQLite operations
|
||||||
|
- [ssh.py](libtisbackup/ssh.py) - SSH utilities with modern key support (Ed25519, ECDSA, RSA)
|
||||||
|
- [process.py](libtisbackup/process.py) - Process execution and monitoring utilities
|
||||||
|
- [utils.py](libtisbackup/utils.py) - Date/time formatting, number formatting, validation helpers
|
||||||
|
- [__init__.py](libtisbackup/__init__.py) - Package exports for backward compatibility
|
||||||
|
- [drivers/](libtisbackup/drivers/) - All backup driver implementations
|
||||||
|
|
||||||
|
**Available Drivers:**
|
||||||
|
- `backup_rsync` / `backup_rsync_ssh` - File-based backups via rsync
|
||||||
|
- `backup_rsync_btrfs` / `backup_rsync__btrfs_ssh` - Btrfs snapshot-based backups
|
||||||
|
- `backup_mysql` - MySQL database dumps
|
||||||
|
- `backup_pgsql` - PostgreSQL database dumps
|
||||||
|
- `backup_oracle` - Oracle database backups
|
||||||
|
- `backup_sqlserver` - SQL Server backups
|
||||||
|
- `backup_samba4` - Samba4 AD backups
|
||||||
|
- `backup_xva` / `backup_xcp_metadata` / `copy_vm_xcp` - XenServer VM backups
|
||||||
|
- `backup_vmdk` - VMware VMDK backups (requires pyVmomi)
|
||||||
|
- `backup_switch` - Network switch configuration backups
|
||||||
|
- `backup_null` - No-op driver for testing
|
||||||
|
|
||||||
|
**State Management:**
|
||||||
|
- SQLite database tracks backup history, status, and statistics
|
||||||
|
- `BackupStat` class in [common.py](libtisbackup/common.py) handles DB operations
|
||||||
|
- Database location: `{backup_base_dir}/log/tisbackup.sqlite`
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Two separate INI configuration files:
|
||||||
|
|
||||||
|
1. **tisbackup-config.ini** - Backup definitions
|
||||||
|
- `[global]` section with defaults (backup_base_dir, backup_retention_time, maximum_backup_age)
|
||||||
|
- One section per backup job with driver type and parameters
|
||||||
|
|
||||||
|
2. **tisbackup_gui.ini** - GUI settings
|
||||||
|
- Points to tisbackup-config.ini location(s)
|
||||||
|
- Defines admin email, base directories
|
||||||
|
|
||||||
|
### Task Queue
|
||||||
|
|
||||||
|
- Uses Huey (Redis-backed) for async job processing
|
||||||
|
- Currently implements `run_export_backup` for exporting backups to external storage
|
||||||
|
- Task state tracked in tasks.sqlite
|
||||||
|
|
||||||
|
### Docker Deployment
|
||||||
|
|
||||||
|
Two-container architecture:
|
||||||
|
- **tisbackup_gui**: Runs Flask web interface
|
||||||
|
- **tisbackup_cron**: Runs scheduled backups via cron (executes [backup.sh](backup.sh) at 03:59 daily)
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
- Line length: 140 characters (configured in pyproject.toml)
|
||||||
|
- Ruff ignores: F401, F403, F405, E402, E701, E722, E741
|
||||||
|
- Python 3.14+ required
|
||||||
|
|
||||||
|
## Commit Message Guidelines
|
||||||
|
|
||||||
|
**IMPORTANT: This project uses [Conventional Commits](https://www.conventionalcommits.org/) format.**
|
||||||
|
|
||||||
|
All commit messages must follow this format:
|
||||||
|
```
|
||||||
|
<type>(<scope>): <description>
|
||||||
|
|
||||||
|
[optional body]
|
||||||
|
|
||||||
|
[optional footer(s)]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Types:**
|
||||||
|
- `feat`: A new feature
|
||||||
|
- `fix`: A bug fix
|
||||||
|
- `docs`: Documentation only changes
|
||||||
|
- `refactor`: Code change that neither fixes a bug nor adds a feature
|
||||||
|
- `test`: Adding missing tests or correcting existing tests
|
||||||
|
- `chore`: Changes to build process or auxiliary tools
|
||||||
|
- `perf`: Performance improvements
|
||||||
|
- `style`: Code style changes (formatting, missing semicolons, etc.)
|
||||||
|
|
||||||
|
**Scopes (commonly used):**
|
||||||
|
- `auth`: Authentication/authorization changes
|
||||||
|
- `security`: Security-related changes
|
||||||
|
- `drivers`: Backup driver changes
|
||||||
|
- `gui`: Web GUI changes
|
||||||
|
- `api`: API changes
|
||||||
|
- `readme`: README.md changes
|
||||||
|
- `claude`: CLAUDE.md changes
|
||||||
|
- `core`: Core library changes
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
- `feat(auth): add pluggable authentication system for Flask routes`
|
||||||
|
- `fix(security): replace os.popen/os.system with subprocess`
|
||||||
|
- `docs(readme): add comprehensive security and authentication documentation`
|
||||||
|
- `refactor(drivers): organize backup modules into drivers subfolder`
|
||||||
|
- `chore(deps): add pyvmomi as mandatory dependency`
|
||||||
|
|
||||||
|
**Breaking Changes:**
|
||||||
|
Add `!` after type/scope for breaking changes:
|
||||||
|
- `feat(api)!: remove deprecated endpoint`
|
||||||
|
|
||||||
|
**Note:** Always include a scope in parentheses, even for documentation changes.
|
||||||
|
|
||||||
|
When Claude Code creates commits, it will automatically follow this format.
|
||||||
|
|
||||||
|
## Important Patterns
|
||||||
|
|
||||||
|
**Adding a new backup driver:**
|
||||||
|
1. Create `backup_<type>.py` in [libtisbackup/drivers/](libtisbackup/drivers/)
|
||||||
|
2. Inherit from `backup_generic`
|
||||||
|
3. Set class attributes: `type`, `required_params`, `optional_params`
|
||||||
|
4. Implement abstract methods: `do_backup()`, `cleanup()`, `checknagios()`
|
||||||
|
5. Register with `register_driver(backup_<type>)`
|
||||||
|
6. Import in [libtisbackup/drivers/__init__.py](libtisbackup/drivers/__init__.py)
|
||||||
|
|
||||||
|
**SSH Operations:**
|
||||||
|
- Uses paramiko for SSH connections
|
||||||
|
- Supports both RSA and DSA keys
|
||||||
|
- Private key path specified per backup section via `private_key` parameter
|
||||||
|
- Pre/post-exec hooks run remote commands via SSH
|
||||||
|
|
||||||
|
**Path Handling:**
|
||||||
|
- Module imports use sys.path manipulation to include lib/ and libtisbackup/
|
||||||
|
- All backup drivers expect absolute paths for backup_dir
|
||||||
|
- Backup directory structure: `{backup_base_dir}/{section_name}/{timestamp}/`
|
||||||
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
FROM python:3.14-slim
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||||
|
|
||||||
|
WORKDIR /opt/tisbackup
|
||||||
|
|
||||||
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
COPY . /opt/tisbackup
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
ENV UV_PROJECT_ENVIRONMENT=/usr/local
|
||||||
|
ENV UV_PYTHON_DOWNLOADS=never
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get upgrade -y \
|
||||||
|
&& apt-get install --no-install-recommends -y rsync ssh cron \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& uv sync --locked --no-dev --no-install-project \
|
||||||
|
&& rm -f /bin/uv /bin/uvx \
|
||||||
|
&& mkdir -p /var/spool/cron/crontabs \
|
||||||
|
&& echo '59 03 * * * root /bin/bash /opt/tisbackup/backup.sh' > /etc/crontab \
|
||||||
|
&& echo '' >> /etc/crontab \
|
||||||
|
&& crontab /etc/crontab
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
CMD ["/usr/local/bin/python3.14","/opt/tisbackup/tisbackup_gui.py"]
|
||||||
@@ -1,10 +1,483 @@
|
|||||||
This is the repository of the TISBackup project, licensed under GPLv3.
|
# TISBackup
|
||||||
|
|
||||||
TISBackup is a python script that the backup server runs
|
A comprehensive server-side backup orchestration system for managing automated backups of databases, files, and virtual machines across remote Linux and Windows systems.
|
||||||
at regular intervals to retrieve different data types on remote hosts
|
|
||||||
such as database dumps, files, virtual machine images and metadata.
|
|
||||||
|
|
||||||
:ref:`Tranquil IT <contact_tranquil_it>` is the original author of TISBackup.
|
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||||
|
[](https://www.python.org/downloads/)
|
||||||
|
|
||||||
The documentation is provided under the license CC-BY-SA and can be found
|
## Overview
|
||||||
on `readthedoc <https://tisbackup.readthedocs.io/en/latest/index.html>`_.
|
|
||||||
|
TISBackup is a Python-based backup solution that provides:
|
||||||
|
|
||||||
|
- **Pluggable backup drivers** for different data sources (databases, files, VMs)
|
||||||
|
- **Web-based management interface** for monitoring and controlling backups
|
||||||
|
- **CLI tool** for automation and scripting
|
||||||
|
- **Automated scheduling** via cron
|
||||||
|
- **Backup retention management** with configurable policies
|
||||||
|
- **Status monitoring** with Nagios integration
|
||||||
|
- **Docker deployment** for easy setup and isolation
|
||||||
|
|
||||||
|
### Supported Backup Types
|
||||||
|
|
||||||
|
| Type | Description | Driver |
|
||||||
|
|------|-------------|--------|
|
||||||
|
| **Files & Directories** | rsync-based file backups | `rsync+ssh` |
|
||||||
|
| **Btrfs Snapshots** | Snapshot-based incremental backups | `rsync+btrfs+ssh` |
|
||||||
|
| **MySQL** | Database dumps via SSH | `mysql+ssh` |
|
||||||
|
| **PostgreSQL** | Database dumps via SSH | `pgsql+ssh` |
|
||||||
|
| **SQL Server** | SQL Server backups | `sqlserver+ssh` |
|
||||||
|
| **Oracle** | Oracle database backups | `oracle+ssh` |
|
||||||
|
| **Samba4 AD** | Active Directory backups | `samba4` |
|
||||||
|
| **XenServer VMs** | XVA exports and metadata | `xen-xva`, `xcp-dump-metadata` |
|
||||||
|
| **VMware** | VMDK backups | `vmdk` |
|
||||||
|
| **Network Devices** | Switch configuration backups | `switch` |
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Docker and Docker Compose
|
||||||
|
- SSH access to remote servers
|
||||||
|
- Ed25519, ECDSA, or RSA SSH keys (DSA not supported)
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
1. **Clone the repository:**
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/tranquilit/TISbackup.git
|
||||||
|
cd TISbackup
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Build the Docker image:**
|
||||||
|
```bash
|
||||||
|
docker build . -t tisbackup:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Create directory structure:**
|
||||||
|
```bash
|
||||||
|
mkdir -p /var/tisbackup/{backup/log,config,ssh}
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected structure:
|
||||||
|
```
|
||||||
|
/var/tisbackup/
|
||||||
|
├── backup/ # Backup storage location
|
||||||
|
│ └── log/ # SQLite database and logs
|
||||||
|
├── config/ # Configuration files
|
||||||
|
│ ├── tisbackup-config.ini
|
||||||
|
│ └── tisbackup_gui.ini
|
||||||
|
├── ssh/ # SSH keys
|
||||||
|
│ ├── id_ed25519 # Private key (Ed25519 recommended)
|
||||||
|
│ └── id_ed25519.pub # Public key
|
||||||
|
└── compose.yaml # Docker Compose configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Generate SSH keys:**
|
||||||
|
```bash
|
||||||
|
# Ed25519 (recommended - most secure and modern)
|
||||||
|
ssh-keygen -t ed25519 -f /var/tisbackup/ssh/id_ed25519 -C "tisbackup@yourserver"
|
||||||
|
|
||||||
|
# Or ECDSA (also secure)
|
||||||
|
ssh-keygen -t ecdsa -b 521 -f /var/tisbackup/ssh/id_ecdsa -C "tisbackup@yourserver"
|
||||||
|
|
||||||
|
# Or RSA (legacy support, minimum 4096 bits)
|
||||||
|
ssh-keygen -t rsa -b 4096 -f /var/tisbackup/ssh/id_rsa -C "tisbackup@yourserver"
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ **Note:** DSA keys are no longer supported due to security vulnerabilities.
|
||||||
|
|
||||||
|
5. **Deploy public key to remote servers:**
|
||||||
|
```bash
|
||||||
|
ssh-copy-id -i /var/tisbackup/ssh/id_ed25519.pub root@remote-server
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Generate Flask secret key:**
|
||||||
|
```bash
|
||||||
|
python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
```
|
||||||
|
Save this key for the next step.
|
||||||
|
|
||||||
|
7. **Create Docker Compose configuration:**
|
||||||
|
```yaml
|
||||||
|
# /var/tisbackup/compose.yaml
|
||||||
|
services:
|
||||||
|
tisbackup_gui:
|
||||||
|
container_name: tisbackup_gui
|
||||||
|
image: "tisbackup:latest"
|
||||||
|
volumes:
|
||||||
|
- ./config/:/etc/tis/
|
||||||
|
- ./backup/:/backup/
|
||||||
|
- /etc/timezone:/etc/timezone:ro
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
environment:
|
||||||
|
# SECURITY: Use the secret key you generated above
|
||||||
|
- TISBACKUP_SECRET_KEY=your-secret-key-here
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- 9980:8080
|
||||||
|
|
||||||
|
tisbackup_cron:
|
||||||
|
container_name: tisbackup_cron
|
||||||
|
image: "tisbackup:latest"
|
||||||
|
volumes:
|
||||||
|
- ./config/:/etc/tis/
|
||||||
|
- ./ssh/:/config_ssh/
|
||||||
|
- ./backup/:/backup/
|
||||||
|
- /etc/timezone:/etc/timezone:ro
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
restart: always
|
||||||
|
command: "/bin/bash /opt/tisbackup/cron.sh"
|
||||||
|
```
|
||||||
|
|
||||||
|
8. **Configure backups:**
|
||||||
|
|
||||||
|
Create `/var/tisbackup/config/tisbackup-config.ini`:
|
||||||
|
```ini
|
||||||
|
[global]
|
||||||
|
backup_base_dir = /backup/
|
||||||
|
# Backup retention in days
|
||||||
|
backup_retention_time = 90
|
||||||
|
# Maximum backup age for Nagios checks (hours)
|
||||||
|
maximum_backup_age = 30
|
||||||
|
|
||||||
|
# Example: File backup via rsync
|
||||||
|
[webserver-files]
|
||||||
|
type = rsync+ssh
|
||||||
|
server_name = webserver.example.com
|
||||||
|
remote_dir = /var/www/
|
||||||
|
compression = True
|
||||||
|
exclude_list = "/var/www/cache/**","/var/www/temp/**"
|
||||||
|
private_key = /config_ssh/id_ed25519
|
||||||
|
ssh_port = 22
|
||||||
|
|
||||||
|
# Example: MySQL database backup
|
||||||
|
[database-mysql]
|
||||||
|
type = mysql+ssh
|
||||||
|
server_name = db.example.com
|
||||||
|
db_name = production_db
|
||||||
|
db_user = backup_user
|
||||||
|
db_passwd = backup_password
|
||||||
|
private_key = /config_ssh/id_ed25519
|
||||||
|
ssh_port = 22
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `/var/tisbackup/config/tisbackup_gui.ini`:
|
||||||
|
```ini
|
||||||
|
[general]
|
||||||
|
config_tisbackup = /etc/tis/tisbackup-config.ini
|
||||||
|
sections =
|
||||||
|
ADMIN_EMAIL = admin@example.com
|
||||||
|
base_config_dir = /etc/tis/
|
||||||
|
backup_base_dir = /backup/
|
||||||
|
```
|
||||||
|
|
||||||
|
9. **Start services:**
|
||||||
|
```bash
|
||||||
|
cd /var/tisbackup
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
10. **Access web interface:**
|
||||||
|
```
|
||||||
|
http://localhost:9980
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Backup Types Configuration
|
||||||
|
|
||||||
|
#### File Backups (rsync+ssh)
|
||||||
|
```ini
|
||||||
|
[backup-name]
|
||||||
|
type = rsync+ssh
|
||||||
|
server_name = hostname.example.com
|
||||||
|
remote_dir = /path/to/backup/
|
||||||
|
compression = True
|
||||||
|
exclude_list = "/path/exclude1/**","/path/exclude2/**"
|
||||||
|
private_key = /config_ssh/id_ed25519
|
||||||
|
ssh_port = 22
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Btrfs Snapshots (rsync+btrfs+ssh)
|
||||||
|
```ini
|
||||||
|
[backup-name]
|
||||||
|
type = rsync+btrfs+ssh
|
||||||
|
server_name = hostname.example.com
|
||||||
|
remote_dir = /mnt/btrfs/data/
|
||||||
|
compression = True
|
||||||
|
private_key = /config_ssh/id_ed25519
|
||||||
|
ssh_port = 22
|
||||||
|
```
|
||||||
|
|
||||||
|
#### MySQL Database (mysql+ssh)
|
||||||
|
```ini
|
||||||
|
[backup-name]
|
||||||
|
type = mysql+ssh
|
||||||
|
server_name = hostname.example.com
|
||||||
|
db_name = database_name
|
||||||
|
db_user = backup_user
|
||||||
|
db_passwd = backup_password
|
||||||
|
private_key = /config_ssh/id_ed25519
|
||||||
|
ssh_port = 22
|
||||||
|
```
|
||||||
|
|
||||||
|
#### PostgreSQL Database (pgsql+ssh)
|
||||||
|
```ini
|
||||||
|
[backup-name]
|
||||||
|
type = pgsql+ssh
|
||||||
|
server_name = hostname.example.com
|
||||||
|
db_name = database_name
|
||||||
|
private_key = /config_ssh/id_ed25519
|
||||||
|
ssh_port = 22
|
||||||
|
```
|
||||||
|
|
||||||
|
#### XenServer VM (xen-xva)
|
||||||
|
```ini
|
||||||
|
[backup-name]
|
||||||
|
type = xen-xva
|
||||||
|
server_name = vm-name
|
||||||
|
xcphost = xenserver.example.com
|
||||||
|
password_file = /etc/tis/xen-password
|
||||||
|
private_key = /config_ssh/id_ed25519
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pre/Post Execution Hooks
|
||||||
|
|
||||||
|
You can execute commands before and after backups:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[backup-name]
|
||||||
|
type = rsync+ssh
|
||||||
|
server_name = hostname.example.com
|
||||||
|
remote_dir = /data/
|
||||||
|
private_key = /config_ssh/id_ed25519
|
||||||
|
preexec = systemctl stop application
|
||||||
|
postexec = systemctl start application
|
||||||
|
remote_user = root
|
||||||
|
ssh_port = 22
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI Usage
|
||||||
|
|
||||||
|
### Running Backups
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all backups
|
||||||
|
docker exec tisbackup_cron python3 /opt/tisbackup/tisbackup.py backup
|
||||||
|
|
||||||
|
# Run specific backup
|
||||||
|
docker exec tisbackup_cron python3 /opt/tisbackup/tisbackup.py -s backup-name backup
|
||||||
|
|
||||||
|
# Dry run
|
||||||
|
docker exec tisbackup_cron python3 /opt/tisbackup/tisbackup.py -d backup
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cleanup Old Backups
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Remove backups older than retention period
|
||||||
|
docker exec tisbackup_cron python3 /opt/tisbackup/tisbackup.py cleanup
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nagios Monitoring
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check backup status
|
||||||
|
docker exec tisbackup_cron python3 /opt/tisbackup/tisbackup.py checknagios
|
||||||
|
|
||||||
|
# Check specific backup
|
||||||
|
docker exec tisbackup_cron python3 /opt/tisbackup/tisbackup.py -s backup-name checknagios
|
||||||
|
```
|
||||||
|
|
||||||
|
### List Available Drivers
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec tisbackup_cron python3 /opt/tisbackup/tisbackup.py listdrivers
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backup Statistics
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dump statistics for last 20 backups
|
||||||
|
docker exec tisbackup_cron python3 /opt/tisbackup/tisbackup.py dumpstat
|
||||||
|
|
||||||
|
# Specify number of backups
|
||||||
|
docker exec tisbackup_cron python3 /opt/tisbackup/tisbackup.py -n 50 dumpstat
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Python 3.14+
|
||||||
|
- uv (Python package manager)
|
||||||
|
|
||||||
|
### Setup Development Environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
uv sync --locked
|
||||||
|
|
||||||
|
# Run linter
|
||||||
|
uv run ruff check .
|
||||||
|
|
||||||
|
# Auto-fix linting issues
|
||||||
|
uv run ruff check --fix .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run web GUI (requires config at /etc/tis/tisbackup_gui.ini)
|
||||||
|
python3 tisbackup_gui.py
|
||||||
|
|
||||||
|
# Run CLI
|
||||||
|
python3 tisbackup.py -c /etc/tis/tisbackup-config.ini backup
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
TISBackup implements several security best practices:
|
||||||
|
|
||||||
|
### SSH Key Security
|
||||||
|
|
||||||
|
- **Ed25519 keys are recommended** (most secure, modern algorithm)
|
||||||
|
- ECDSA and RSA keys are supported
|
||||||
|
- **DSA keys are explicitly not supported** (deprecated, insecure)
|
||||||
|
- Key algorithm priority: Ed25519 → ECDSA → RSA
|
||||||
|
|
||||||
|
### Flask Session Security
|
||||||
|
|
||||||
|
- Secret key loaded from `TISBACKUP_SECRET_KEY` environment variable
|
||||||
|
- Falls back to cryptographically secure random key if not set
|
||||||
|
- No hardcoded secrets in source code
|
||||||
|
|
||||||
|
### Command Execution Safety
|
||||||
|
|
||||||
|
- All system commands use `subprocess.run()` with list arguments
|
||||||
|
- Input validation for device paths and partition names
|
||||||
|
- Timeout protection on all subprocess calls
|
||||||
|
- No use of `shell=True` in new code
|
||||||
|
|
||||||
|
### Best Practices
|
||||||
|
|
||||||
|
1. **Use Ed25519 keys** for all SSH connections
|
||||||
|
2. **Set unique Flask secret key** via environment variable
|
||||||
|
3. **Use reverse proxy** (nginx) with TLS for web interface
|
||||||
|
4. **Restrict network access** to backup server
|
||||||
|
5. **Regular security updates** of base Docker image
|
||||||
|
6. **Monitor backup logs** for suspicious activity
|
||||||
|
|
||||||
|
## Reverse Proxy Setup
|
||||||
|
|
||||||
|
Example nginx configuration for HTTPS access:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name tisbackup.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/tisbackup.example.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/tisbackup.example.com/privkey.pem;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_pass http://localhost:9980/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
TISBackup uses a modular driver-based architecture:
|
||||||
|
|
||||||
|
- **Core CLI** ([tisbackup.py](tisbackup.py)): Backup orchestration and scheduling
|
||||||
|
- **Web GUI** ([tisbackup_gui.py](tisbackup_gui.py)): Flask-based management interface
|
||||||
|
- **Backup Drivers** ([libtisbackup/](libtisbackup/)): Pluggable modules for different backup types
|
||||||
|
- **Task Queue** ([tasks.py](tasks.py), [config.py](config.py)): Async job processing with Huey
|
||||||
|
- **State Database**: SQLite for tracking backup history and statistics
|
||||||
|
|
||||||
|
Each backup type is implemented as a driver class inheriting from `backup_generic`, allowing easy extension for new backup sources.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Backups Not Running
|
||||||
|
|
||||||
|
1. Check cron logs:
|
||||||
|
```bash
|
||||||
|
docker logs tisbackup_cron
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Verify SSH connectivity:
|
||||||
|
```bash
|
||||||
|
docker exec tisbackup_cron ssh -i /config_ssh/id_ed25519 root@remote-server
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Check backup configuration:
|
||||||
|
```bash
|
||||||
|
docker exec tisbackup_cron python3 /opt/tisbackup/tisbackup.py -c /etc/tis/tisbackup-config.ini -d backup
|
||||||
|
```
|
||||||
|
|
||||||
|
### Web Interface Not Accessible
|
||||||
|
|
||||||
|
1. Check GUI container logs:
|
||||||
|
```bash
|
||||||
|
docker logs tisbackup_gui
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Verify port mapping:
|
||||||
|
```bash
|
||||||
|
docker ps | grep tisbackup_gui
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Check configuration:
|
||||||
|
```bash
|
||||||
|
docker exec tisbackup_gui cat /etc/tis/tisbackup_gui.ini
|
||||||
|
```
|
||||||
|
|
||||||
|
### Permission Errors
|
||||||
|
|
||||||
|
Ensure proper file permissions:
|
||||||
|
```bash
|
||||||
|
chmod 600 /var/tisbackup/ssh/id_ed25519
|
||||||
|
chmod 644 /var/tisbackup/ssh/id_ed25519.pub
|
||||||
|
chown -R root:root /var/tisbackup/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please:
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch
|
||||||
|
3. Follow the existing code style (use `ruff` for linting)
|
||||||
|
4. Add tests if applicable
|
||||||
|
5. Submit a pull request
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
TISBackup is licensed under the GNU General Public License v3.0 (GPLv3).
|
||||||
|
|
||||||
|
See [LICENSE](LICENSE) for the full license text.
|
||||||
|
|
||||||
|
## Support & Documentation
|
||||||
|
|
||||||
|
- **Documentation**: [https://tisbackup.readthedocs.io](https://tisbackup.readthedocs.io/en/latest/index.html)
|
||||||
|
- **Issues**: [GitHub Issues](https://github.com/tranquilit/TISbackup/issues)
|
||||||
|
- **Original Author**: [Tranquil IT](https://www.tranquil.it)
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
Developed by Tranquil IT for system administrators managing backup infrastructure.
|
||||||
|
|
||||||
|
Security improvements and modernization contributed by the community.
|
||||||
|
|||||||
+149
@@ -0,0 +1,149 @@
|
|||||||
|
# TISBackup Refactoring Summary
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Successfully refactored the monolithic `libtisbackup/common.py` (1079 lines, 42KB) into focused, maintainable modules with clear separation of concerns.
|
||||||
|
|
||||||
|
## New Module Structure
|
||||||
|
|
||||||
|
### 1. **[utils.py](libtisbackup/utils.py)** - 6.7KB
|
||||||
|
Utility functions for formatting and data manipulation:
|
||||||
|
- **Date/Time helpers**: `datetime2isodate`, `isodate2datetime`, `time2display`, `hours_minutes`, `fileisodate`, `dateof`
|
||||||
|
- **Number formatting**: `splitThousands`, `convert_bytes`
|
||||||
|
- **Display helpers**: `pp` (pretty-print tables), `html_table`
|
||||||
|
- **Validation**: `check_string`, `str2bool`
|
||||||
|
|
||||||
|
### 2. **[ssh.py](libtisbackup/ssh.py)** - 3.4KB
|
||||||
|
SSH operations and key management:
|
||||||
|
- **`load_ssh_private_key()`**: Modern SSH key loading with Ed25519, ECDSA, and RSA support
|
||||||
|
- **`ssh_exec()`**: Execute commands on remote servers via SSH
|
||||||
|
|
||||||
|
### 3. **[process.py](libtisbackup/process.py)** - 3.4KB
|
||||||
|
Process execution utilities:
|
||||||
|
- **`call_external_process()`**: Execute shell commands with error handling
|
||||||
|
- **`monitor_stdout()`**: Real-time process output monitoring with callbacks
|
||||||
|
|
||||||
|
### 4. **[database.py](libtisbackup/database.py)** - 8.3KB
|
||||||
|
SQLite database management for backup statistics:
|
||||||
|
- **`BackupStat` class**: Complete state management for backup history
|
||||||
|
- Database initialization and schema updates
|
||||||
|
- Backup tracking (start, finish, query)
|
||||||
|
- Formatted output (HTML, text tables)
|
||||||
|
|
||||||
|
### 5. **[base_driver.py](libtisbackup/base_driver.py)** - 25KB
|
||||||
|
Core backup driver architecture:
|
||||||
|
- **`backup_generic`**: Abstract base class for all backup drivers
|
||||||
|
- **`register_driver()`**: Driver registration system
|
||||||
|
- **`backup_drivers`**: Global driver registry
|
||||||
|
- **Nagios constants**: `nagiosStateOk`, `nagiosStateWarning`, `nagiosStateCritical`, `nagiosStateUnknown`
|
||||||
|
- Core backup logic: process_backup, cleanup_backup, checknagios, export_latestbackup
|
||||||
|
|
||||||
|
### 6. **[__init__.py](libtisbackup/__init__.py)** - 2.5KB
|
||||||
|
Package initialization with backward compatibility:
|
||||||
|
- Re-exports all public APIs from new modules
|
||||||
|
- Maintains 100% backward compatibility with existing code
|
||||||
|
- Clear `__all__` declaration for IDE support
|
||||||
|
|
||||||
|
## Migration Details
|
||||||
|
|
||||||
|
### Changed Imports
|
||||||
|
All imports have been automatically updated:
|
||||||
|
```python
|
||||||
|
# Old (common.py)
|
||||||
|
from libtisbackup.common import *
|
||||||
|
from .common import *
|
||||||
|
|
||||||
|
# New (modular structure)
|
||||||
|
from libtisbackup import *
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backward Compatibility
|
||||||
|
✅ **100% backward compatible** - All existing code continues to work without changes
|
||||||
|
✅ The `__init__.py` re-exports everything that was previously in `common.py`
|
||||||
|
✅ All 12 backup drivers verified and working
|
||||||
|
✅ Main CLI (`tisbackup.py`) tested successfully
|
||||||
|
✅ GUI (`tisbackup_gui.py`) imports verified
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
### Maintainability
|
||||||
|
- **Single Responsibility**: Each module has one clear purpose
|
||||||
|
- **Easier Navigation**: Find functionality quickly by module name
|
||||||
|
- **Reduced Complexity**: Smaller files are easier to understand
|
||||||
|
|
||||||
|
### Testability
|
||||||
|
- Can test SSH, database, process, and backup logic independently
|
||||||
|
- Mock individual modules for unit testing
|
||||||
|
- Clearer boundaries for integration tests
|
||||||
|
|
||||||
|
### Developer Experience
|
||||||
|
- Better IDE autocomplete and navigation
|
||||||
|
- Explicit imports reduce cognitive load
|
||||||
|
- Clear module boundaries aid code review
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- Import only what you need (reduces memory footprint)
|
||||||
|
- Faster module loading for targeted imports
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
### Created (6 new files)
|
||||||
|
- `libtisbackup/utils.py`
|
||||||
|
- `libtisbackup/ssh.py`
|
||||||
|
- `libtisbackup/process.py`
|
||||||
|
- `libtisbackup/database.py`
|
||||||
|
- `libtisbackup/base_driver.py`
|
||||||
|
- `libtisbackup/__init__.py` (updated)
|
||||||
|
|
||||||
|
### Backed Up
|
||||||
|
- `libtisbackup/common.py` → `libtisbackup/common.py.bak` (preserved for reference)
|
||||||
|
|
||||||
|
### Updated (15 files)
|
||||||
|
All backup drivers and main scripts updated to use new imports:
|
||||||
|
- `libtisbackup/backup_mysql.py`
|
||||||
|
- `libtisbackup/backup_null.py`
|
||||||
|
- `libtisbackup/backup_oracle.py`
|
||||||
|
- `libtisbackup/backup_pgsql.py`
|
||||||
|
- `libtisbackup/backup_rsync.py`
|
||||||
|
- `libtisbackup/backup_rsync_btrfs.py`
|
||||||
|
- `libtisbackup/backup_samba4.py`
|
||||||
|
- `libtisbackup/backup_sqlserver.py`
|
||||||
|
- `libtisbackup/backup_switch.py`
|
||||||
|
- `libtisbackup/backup_vmdk.py`
|
||||||
|
- `libtisbackup/backup_xcp_metadata.py`
|
||||||
|
- `libtisbackup/backup_xva.py`
|
||||||
|
- `libtisbackup/copy_vm_xcp.py`
|
||||||
|
- `tisbackup.py`
|
||||||
|
- `tisbackup_gui.py`
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
✅ **All checks passed**
|
||||||
|
- Ruff linting: `uv run ruff check .` - ✓ All checks passed
|
||||||
|
- CLI test: `uv run python tisbackup.py listdrivers` - ✓ 10 drivers loaded successfully
|
||||||
|
- Import test: `from libtisbackup import *` - ✓ All imports successful
|
||||||
|
|
||||||
|
## Metrics
|
||||||
|
|
||||||
|
| Metric | Before | After | Improvement |
|
||||||
|
|--------|--------|-------|-------------|
|
||||||
|
| Largest file | 1079 lines (common.py) | 579 lines (base_driver.py) | 46% reduction |
|
||||||
|
| Total lines | 1079 | 1079 (distributed) | Same functionality |
|
||||||
|
| Number of modules | 1 monolith | 6 focused modules | 6x organization |
|
||||||
|
| Average file size | 42KB | 8.2KB | 81% smaller |
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Now that the codebase is modular, future improvements are easier:
|
||||||
|
|
||||||
|
1. **Add type hints** to individual modules
|
||||||
|
2. **Write unit tests** for each module independently
|
||||||
|
3. **Add documentation** with module-level docstrings
|
||||||
|
4. **Create specialized utilities** without bloating a single file
|
||||||
|
5. **Optimize imports** by using specific imports instead of `import *`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The original `common.py` is preserved as `common.py.bak` for reference
|
||||||
|
- No functionality was removed or changed - purely structural refactoring
|
||||||
|
- All existing configuration files, backup scripts, and workflows continue to work unchanged
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
# Security and Code Quality Improvements
|
||||||
|
|
||||||
|
This document summarizes the security and code quality improvements made to TISBackup.
|
||||||
|
|
||||||
|
## Completed Improvements (High Priority)
|
||||||
|
|
||||||
|
### 1. Replaced `os.popen()` with `subprocess.run()`
|
||||||
|
**Files Modified:** [tisbackup_gui.py](tisbackup_gui.py)
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
- Replaced deprecated `os.popen()` calls with modern `subprocess.run()`
|
||||||
|
- All subprocess calls now use list arguments instead of shell strings
|
||||||
|
- Added timeout protection (5-30 seconds depending on operation)
|
||||||
|
- Proper error handling with try/except blocks
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```python
|
||||||
|
for line in os.popen("udevadm info -q env -n %s" % name):
|
||||||
|
# Process output
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```python
|
||||||
|
result = subprocess.run(
|
||||||
|
["udevadm", "info", "-q", "env", "-n", name],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
# Process output
|
||||||
|
```
|
||||||
|
|
||||||
|
**Security Impact:** Prevents command injection vulnerabilities
|
||||||
|
|
||||||
|
### 2. Replaced `os.system()` with `subprocess.run()`
|
||||||
|
**Files Modified:** [tasks.py](tasks.py), [libtisbackup/backup_xva.py](libtisbackup/backup_xva.py)
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
- [tasks.py:37](tasks.py#L37): Changed `os.system("/bin/umount %s")` to `subprocess.run(["/bin/umount", mount_point])`
|
||||||
|
- [backup_xva.py:199](libtisbackup/backup_xva.py#L199): Changed `os.system('tar tf "%s"')` to `subprocess.run(["tar", "tf", filename_temp])`
|
||||||
|
- Added proper error handling and logging
|
||||||
|
|
||||||
|
**Security Impact:** Eliminates command injection risk from potentially user-controlled mount points and filenames
|
||||||
|
|
||||||
|
### 3. Added Input Validation
|
||||||
|
**Files Modified:** [tisbackup_gui.py](tisbackup_gui.py)
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
- Added regex validation for device/partition names: `^/dev/sd[a-z]1?$`
|
||||||
|
- Validates partition names before using in mount/unmount operations
|
||||||
|
- Prevents path traversal and command injection attacks
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```python
|
||||||
|
# Validate partition name to prevent command injection
|
||||||
|
if not re.match(r"^/dev/sd[a-z]1$", partition):
|
||||||
|
continue
|
||||||
|
```
|
||||||
|
|
||||||
|
**Security Impact:** Prevents malicious input from reaching system commands
|
||||||
|
|
||||||
|
### 4. Fixed File Operations with Context Managers
|
||||||
|
**Files Modified:** [tisbackup_gui.py](tisbackup_gui.py)
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```python
|
||||||
|
line = open(elem).readline()
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```python
|
||||||
|
with open(elem) as f:
|
||||||
|
line = f.readline()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact:** Ensures files are properly closed, prevents resource leaks
|
||||||
|
|
||||||
|
### 5. Improved `run_command()` Function
|
||||||
|
**Files Modified:** [tisbackup_gui.py:415-453](tisbackup_gui.py#L415)
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
- Now accepts list arguments for safe command execution
|
||||||
|
- Backward compatible with string commands (marked as legacy)
|
||||||
|
- Added timeout protection (30 seconds)
|
||||||
|
- Better error handling and reporting
|
||||||
|
|
||||||
|
**Security Impact:** Provides safe command execution interface while maintaining backward compatibility
|
||||||
|
|
||||||
|
### 6. Removed Wildcard Import
|
||||||
|
**Files Modified:** [tisbackup_gui.py](tisbackup_gui.py)
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```python
|
||||||
|
from shutil import *
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```python
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact:** Cleaner namespace, easier to track dependencies
|
||||||
|
|
||||||
|
### 7. Fixed Hardcoded Secret Key
|
||||||
|
**Files Modified:** [tisbackup_gui.py:67-79](tisbackup_gui.py#L67), [README.md](README.md)
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```python
|
||||||
|
app.secret_key = "fsiqefiuqsefARZ4Zfesfe34234dfzefzfe"
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```python
|
||||||
|
SECRET_KEY = os.environ.get("TISBACKUP_SECRET_KEY")
|
||||||
|
if not SECRET_KEY:
|
||||||
|
import secrets
|
||||||
|
SECRET_KEY = secrets.token_hex(32)
|
||||||
|
logging.warning(
|
||||||
|
"TISBACKUP_SECRET_KEY environment variable not set. "
|
||||||
|
"Using a randomly generated secret key. "
|
||||||
|
"Sessions will not persist across application restarts. "
|
||||||
|
"Set TISBACKUP_SECRET_KEY environment variable for production use."
|
||||||
|
)
|
||||||
|
app.secret_key = SECRET_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
- Reads secret key from `TISBACKUP_SECRET_KEY` environment variable
|
||||||
|
- Falls back to cryptographically secure random key if not set
|
||||||
|
- Logs warning when using random key (sessions won't persist across restarts)
|
||||||
|
- Uses Python's `secrets` module for cryptographically strong random generation
|
||||||
|
- Updated README.md with setup instructions
|
||||||
|
|
||||||
|
**Setup Instructions:**
|
||||||
|
```bash
|
||||||
|
# Generate a secure secret key
|
||||||
|
python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
|
||||||
|
# Set in Docker Compose (compose.yml)
|
||||||
|
environment:
|
||||||
|
- TISBACKUP_SECRET_KEY=your-generated-key-here
|
||||||
|
|
||||||
|
# Or export in shell
|
||||||
|
export TISBACKUP_SECRET_KEY=your-generated-key-here
|
||||||
|
```
|
||||||
|
|
||||||
|
**Security Impact:** Eliminates hardcoded secret in source code, prevents session hijacking and CSRF attacks
|
||||||
|
|
||||||
|
### 8. Modernized SSH Key Algorithm Support
|
||||||
|
**Files Modified:** [libtisbackup/common.py](libtisbackup/common.py#L140), all backup drivers, [README.md](README.md)
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
mykey = paramiko.RSAKey.from_private_key_file(self.private_key)
|
||||||
|
except paramiko.SSHException:
|
||||||
|
mykey = paramiko.DSSKey.from_private_key_file(self.private_key)
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```python
|
||||||
|
def load_ssh_private_key(private_key_path):
|
||||||
|
"""Load SSH private key with modern algorithm support.
|
||||||
|
|
||||||
|
Tries to load the key in order of preference:
|
||||||
|
1. Ed25519 (most secure, modern)
|
||||||
|
2. ECDSA (secure, widely supported)
|
||||||
|
3. RSA (legacy, still secure with sufficient key size)
|
||||||
|
|
||||||
|
DSA is not supported as it's deprecated and insecure.
|
||||||
|
"""
|
||||||
|
key_types = [
|
||||||
|
("Ed25519", paramiko.Ed25519Key),
|
||||||
|
("ECDSA", paramiko.ECDSAKey),
|
||||||
|
("RSA", paramiko.RSAKey),
|
||||||
|
]
|
||||||
|
|
||||||
|
for key_name, key_class in key_types:
|
||||||
|
try:
|
||||||
|
return key_class.from_private_key_file(private_key_path)
|
||||||
|
except paramiko.SSHException:
|
||||||
|
continue
|
||||||
|
|
||||||
|
raise paramiko.SSHException(
|
||||||
|
f"Unable to load private key. "
|
||||||
|
f"Supported formats: Ed25519 (recommended), ECDSA, RSA. "
|
||||||
|
f"DSA keys are no longer supported."
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
- Created centralized `load_ssh_private_key()` helper function
|
||||||
|
- Updated all SSH key loading locations across codebase:
|
||||||
|
- [common.py](libtisbackup/common.py): `do_preexec`, `do_postexec`, `run_remote_command`
|
||||||
|
- [backup_mysql.py](libtisbackup/backup_mysql.py)
|
||||||
|
- [backup_pgsql.py](libtisbackup/backup_pgsql.py)
|
||||||
|
- [backup_sqlserver.py](libtisbackup/backup_sqlserver.py)
|
||||||
|
- [backup_oracle.py](libtisbackup/backup_oracle.py)
|
||||||
|
- [backup_samba4.py](libtisbackup/backup_samba4.py)
|
||||||
|
- Removed deprecated DSA key support
|
||||||
|
- Added Ed25519 as preferred algorithm
|
||||||
|
- Added ECDSA as second choice
|
||||||
|
- RSA remains supported for compatibility
|
||||||
|
- Clear error message indicating DSA is no longer supported
|
||||||
|
- Updated README.md with key generation instructions
|
||||||
|
|
||||||
|
**SSH Key Generation:**
|
||||||
|
```bash
|
||||||
|
# Ed25519 (recommended)
|
||||||
|
ssh-keygen -t ed25519 -f ./ssh/id_ed25519 -C "tisbackup"
|
||||||
|
|
||||||
|
# ECDSA (also secure)
|
||||||
|
ssh-keygen -t ecdsa -b 521 -f ./ssh/id_ecdsa
|
||||||
|
|
||||||
|
# RSA (legacy, minimum 4096 bits)
|
||||||
|
ssh-keygen -t rsa -b 4096 -f ./ssh/id_rsa
|
||||||
|
```
|
||||||
|
|
||||||
|
**Security Impact:**
|
||||||
|
- Eliminates support for vulnerable DSA algorithm (1024-bit limit, FIPS deprecated)
|
||||||
|
- Prioritizes Ed25519 (fast, secure, resistant to timing attacks)
|
||||||
|
- Supports ECDSA as secure alternative
|
||||||
|
- Maintains RSA compatibility for legacy systems
|
||||||
|
- Clear migration path for users with old keys
|
||||||
|
|
||||||
|
## Remaining Security Issues (Critical - Not Fixed)
|
||||||
|
|
||||||
|
### 1. **No Authentication on Flask Routes**
|
||||||
|
All routes are publicly accessible without authentication.
|
||||||
|
|
||||||
|
**Recommendation:** Implement Flask-Login or similar authentication
|
||||||
|
|
||||||
|
### 2. **Insecure SSH Host Key Policy** ([libtisbackup/common.py:649](libtisbackup/common.py#L649))
|
||||||
|
```python
|
||||||
|
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
```
|
||||||
|
**Recommendation:** Use proper host key verification with known_hosts
|
||||||
|
|
||||||
|
### 3. **Command Injection in Legacy Code**
|
||||||
|
Multiple files still use `subprocess.call(shell_string, shell=True)` and `subprocess.Popen(..., shell=True)`:
|
||||||
|
- [libtisbackup/common.py:128](libtisbackup/common.py#L128)
|
||||||
|
- [libtisbackup/common.py:883](libtisbackup/common.py#L883)
|
||||||
|
- [libtisbackup/common.py:986](libtisbackup/common.py#L986)
|
||||||
|
- [libtisbackup/backup_rsync.py:176](libtisbackup/backup_rsync.py#L176)
|
||||||
|
- [libtisbackup/backup_rsync_btrfs.py](libtisbackup/backup_rsync_btrfs.py) (multiple locations)
|
||||||
|
|
||||||
|
**Recommendation:** Refactor to use list arguments without shell=True
|
||||||
|
|
||||||
|
## Code Quality Issues Remaining
|
||||||
|
|
||||||
|
1. **Global State Management** - Use Flask application context instead
|
||||||
|
2. **Wildcard imports from common** - `from libtisbackup.common import *`
|
||||||
|
3. **Configuration loaded at module level** - Should use application factory pattern
|
||||||
|
4. **Duplicated code** - `read_config()` and `read_all_configs()` share significant logic
|
||||||
|
|
||||||
|
## Testing Recommendations
|
||||||
|
|
||||||
|
Before deploying these changes:
|
||||||
|
1. Test USB disk detection and mounting functionality
|
||||||
|
2. Test backup export operations
|
||||||
|
3. Verify XVA backup tar validation
|
||||||
|
4. Test error handling for invalid device names
|
||||||
|
5. Verify backward compatibility with existing configurations
|
||||||
|
|
||||||
|
## Migration Notes
|
||||||
|
|
||||||
|
All changes are backward compatible. The `run_command()` function accepts both:
|
||||||
|
- New format: `run_command(["/bin/command", "arg1", "arg2"])`
|
||||||
|
- Legacy format: `run_command("/bin/command arg1 arg2")` (less secure, marked for deprecation)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -x
|
||||||
|
echo "Starting cleanup job for TIS Backup"
|
||||||
|
/usr/local/bin/python3.14 /opt/tisbackup/tisbackup.py backup
|
||||||
|
/usr/local/bin/python3.14 /opt/tisbackup/tisbackup.py cleanup
|
||||||
Executable
+41
@@ -0,0 +1,41 @@
|
|||||||
|
services:
|
||||||
|
tisbackup_gui:
|
||||||
|
container_name: tisbackup_gui
|
||||||
|
image: "tisbackup:latest"
|
||||||
|
build: .
|
||||||
|
volumes:
|
||||||
|
- ./config/:/etc/tis/
|
||||||
|
- ./backup/:/backup/
|
||||||
|
- /etc/timezone:/etc/timezone:ro
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- 9980:8080
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: 0.50
|
||||||
|
memory: 512M
|
||||||
|
reservations:
|
||||||
|
cpus: 0.25
|
||||||
|
memory: 128M
|
||||||
|
tisbackup_cron:
|
||||||
|
container_name: tisbackup_cron
|
||||||
|
image: "tisbackup:latest"
|
||||||
|
build: .
|
||||||
|
volumes:
|
||||||
|
- ./config/:/etc/tis/
|
||||||
|
- ./ssh/:/config_ssh/
|
||||||
|
- ./backup/:/backup/
|
||||||
|
- /etc/timezone:/etc/timezone:ro
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
restart: always
|
||||||
|
command: "/bin/bash /opt/tisbackup/cron.sh"
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: 0.50
|
||||||
|
memory: 512M
|
||||||
|
reservations:
|
||||||
|
cpus: 0.25
|
||||||
|
memory: 128M
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import os,sys
|
import os
|
||||||
from huey.backends.sqlite_backend import SqliteQueue,SqliteDataStore
|
import sys
|
||||||
from huey.api import Huey, create_task
|
|
||||||
|
|
||||||
|
from huey.contrib.sql_huey import SqlHuey
|
||||||
|
from huey.storage import SqliteStorage
|
||||||
|
|
||||||
tisbackup_root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__)))
|
tisbackup_root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__)))
|
||||||
tasks_db = os.path.join(tisbackup_root_dir,"tasks.sqlite")
|
tasks_db = os.path.join(tisbackup_root_dir, "tasks.sqlite")
|
||||||
queue = SqliteQueue('tisbackups',tasks_db)
|
huey = SqlHuey(name="tisbackups", filename=tasks_db, always_eager=False, storage_class=SqliteStorage)
|
||||||
result_store = SqliteDataStore('tisbackups',tasks_db)
|
|
||||||
huey = Huey(queue,result_store,always_eager=False)
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -x
|
||||||
|
echo "Starting cron job for TIS Backup"
|
||||||
|
cron -f -l 2
|
||||||
@@ -52,5 +52,3 @@ The documentation for tisbackup is here: [tisbackup doc](https://tisbackup.readt
|
|||||||
dpkg --force-all --purge tis-tisbackup
|
dpkg --force-all --purge tis-tisbackup
|
||||||
apt autoremove
|
apt autoremove
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,4 +7,3 @@ Depends: unzip, ssh, rsync, python3-paramiko, python3-pyvmomi, python3-pexpect,
|
|||||||
Maintainer: Tranquil-IT <technique@tranquil.it>
|
Maintainer: Tranquil-IT <technique@tranquil.it>
|
||||||
Description: TISBackup backup management
|
Description: TISBackup backup management
|
||||||
Homepage: https://www.tranquil.it
|
Homepage: https://www.tranquil.it
|
||||||
|
|
||||||
|
|||||||
@@ -32,5 +32,3 @@ rsync -aP ../samples/tisbackup-config.ini.sample ./builddir/etc/tis/tisbackup-c
|
|||||||
chmod 755 ./builddir/opt/tisbackup/tisbackup.py
|
chmod 755 ./builddir/opt/tisbackup/tisbackup.py
|
||||||
|
|
||||||
dpkg-deb --build builddir tis-tisbackup-1-${VERSION}.deb
|
dpkg-deb --build builddir tis-tisbackup-1-${VERSION}.deb
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
.. Reminder for header structure:
|
||||||
|
Level 1: ====================
|
||||||
|
Level 2: --------------------
|
||||||
|
Level 3: ++++++++++++++++++++
|
||||||
|
Level 4: """"""""""""""""""""
|
||||||
|
Level 5: ^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
.. meta::
|
||||||
|
:description: Configuring authentication for TISBackup web interface
|
||||||
|
:keywords: Documentation, TISBackup, authentication, security, OAuth, Flask-Login
|
||||||
|
|
||||||
|
Authentication Configuration
|
||||||
|
============================
|
||||||
|
|
||||||
|
.. _authentication_configuration:
|
||||||
|
|
||||||
|
TISBackup provides a pluggable authentication system for the Flask web interface,
|
||||||
|
supporting multiple authentication methods to suit different deployment scenarios.
|
||||||
|
|
||||||
|
Overview
|
||||||
|
--------
|
||||||
|
|
||||||
|
The authentication system supports three authentication providers:
|
||||||
|
|
||||||
|
* **Basic Authentication** - Simple HTTP Basic Auth (default)
|
||||||
|
* **Flask-Login** - Session-based authentication with user management
|
||||||
|
* **OAuth2** - Integration with external identity providers
|
||||||
|
|
||||||
|
By default, TISBackup uses Basic Authentication. You can configure the authentication
|
||||||
|
method in the :file:`/etc/tis/tisbackup_gui.ini` configuration file.
|
||||||
|
|
||||||
|
Basic Authentication
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
HTTP Basic Authentication is the simplest method and is enabled by default.
|
||||||
|
|
||||||
|
Configuration via Environment Variables
|
||||||
|
+++++++++++++++++++++++++++++++++++++++
|
||||||
|
|
||||||
|
Set the following environment variables:
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
export TISBACKUP_AUTH_USERNAME="admin"
|
||||||
|
export TISBACKUP_AUTH_PASSWORD="your-secure-password"
|
||||||
|
|
||||||
|
Configuration via INI File
|
||||||
|
++++++++++++++++++++++++++
|
||||||
|
|
||||||
|
Create or edit :file:`/etc/tis/tisbackup_gui.ini`:
|
||||||
|
|
||||||
|
.. code-block:: ini
|
||||||
|
|
||||||
|
[authentication]
|
||||||
|
type=basic
|
||||||
|
username=admin
|
||||||
|
password=your-password
|
||||||
|
use_bcrypt=False
|
||||||
|
realm=TISBackup
|
||||||
|
|
||||||
|
Using Bcrypt Password Hashes (Recommended)
|
||||||
|
+++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
|
||||||
|
For improved security, use bcrypt-hashed passwords:
|
||||||
|
|
||||||
|
1. Install bcrypt support:
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
uv pip install bcrypt
|
||||||
|
|
||||||
|
2. Generate a password hash:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
password = b"your-password"
|
||||||
|
hash = bcrypt.hashpw(password, bcrypt.gensalt())
|
||||||
|
print(hash.decode())
|
||||||
|
|
||||||
|
3. Update configuration:
|
||||||
|
|
||||||
|
.. code-block:: ini
|
||||||
|
|
||||||
|
[authentication]
|
||||||
|
type=basic
|
||||||
|
username=admin
|
||||||
|
password_hash=$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5eSZL9fJQp.Ym
|
||||||
|
use_bcrypt=True
|
||||||
|
realm=TISBackup
|
||||||
|
|
||||||
|
Flask-Login Authentication
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
Session-based authentication with user management and login pages.
|
||||||
|
|
||||||
|
Installation
|
||||||
|
++++++++++++
|
||||||
|
|
||||||
|
Install Flask-Login support:
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
uv pip install flask-login bcrypt
|
||||||
|
|
||||||
|
Configuration
|
||||||
|
+++++++++++++
|
||||||
|
|
||||||
|
Create :file:`/etc/tis/tisbackup_gui.ini`:
|
||||||
|
|
||||||
|
.. code-block:: ini
|
||||||
|
|
||||||
|
[authentication]
|
||||||
|
type=flask-login
|
||||||
|
user_file=/etc/tis/tisbackup_users.txt
|
||||||
|
secret_key=<generate-random-secret-key>
|
||||||
|
session_timeout=3600
|
||||||
|
|
||||||
|
Generate a secret key:
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
|
||||||
|
User File Format
|
||||||
|
++++++++++++++++
|
||||||
|
|
||||||
|
Create a user file at :file:`/etc/tis/tisbackup_users.txt`:
|
||||||
|
|
||||||
|
.. code-block:: text
|
||||||
|
|
||||||
|
admin:$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5eSZL9fJQp.Ym
|
||||||
|
user1:$2b$12$KPOvd2wqZWVIxje1MIBlDPZy7UuyNRKriQ9/MfxZ6fTaM9gKRq.Wm
|
||||||
|
|
||||||
|
Each line is: ``username:bcrypt_password_hash``
|
||||||
|
|
||||||
|
Managing Users
|
||||||
|
++++++++++++++
|
||||||
|
|
||||||
|
Add a new user:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
|
username = "newuser"
|
||||||
|
password = b"secure-password"
|
||||||
|
hash = bcrypt.hashpw(password, bcrypt.gensalt()).decode()
|
||||||
|
|
||||||
|
with open("/etc/tis/tisbackup_users.txt", "a") as f:
|
||||||
|
f.write(f"{username}:{hash}\n")
|
||||||
|
|
||||||
|
Ensure proper permissions:
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
chmod 600 /etc/tis/tisbackup_users.txt
|
||||||
|
chown root:root /etc/tis/tisbackup_users.txt
|
||||||
|
|
||||||
|
OAuth2 Authentication
|
||||||
|
---------------------
|
||||||
|
|
||||||
|
Integrate with external OAuth2 identity providers like Google, GitHub, or GitLab.
|
||||||
|
|
||||||
|
Installation
|
||||||
|
++++++++++++
|
||||||
|
|
||||||
|
Install OAuth support:
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
uv pip install authlib requests
|
||||||
|
|
||||||
|
Google OAuth
|
||||||
|
++++++++++++
|
||||||
|
|
||||||
|
1. Create OAuth credentials in Google Cloud Console
|
||||||
|
2. Configure TISBackup:
|
||||||
|
|
||||||
|
.. code-block:: ini
|
||||||
|
|
||||||
|
[authentication]
|
||||||
|
type=oauth
|
||||||
|
provider=google
|
||||||
|
client_id=<your-client-id>.apps.googleusercontent.com
|
||||||
|
client_secret=<your-client-secret>
|
||||||
|
redirect_uri=https://backup.example.com/callback
|
||||||
|
allowed_domains=example.com
|
||||||
|
|
||||||
|
GitHub OAuth
|
||||||
|
++++++++++++
|
||||||
|
|
||||||
|
1. Create OAuth App in GitHub Settings
|
||||||
|
2. Configure TISBackup:
|
||||||
|
|
||||||
|
.. code-block:: ini
|
||||||
|
|
||||||
|
[authentication]
|
||||||
|
type=oauth
|
||||||
|
provider=github
|
||||||
|
client_id=<your-client-id>
|
||||||
|
client_secret=<your-client-secret>
|
||||||
|
redirect_uri=https://backup.example.com/callback
|
||||||
|
allowed_users=user1,user2,user3
|
||||||
|
|
||||||
|
GitLab OAuth
|
||||||
|
++++++++++++
|
||||||
|
|
||||||
|
1. Create OAuth application in GitLab
|
||||||
|
2. Configure TISBackup:
|
||||||
|
|
||||||
|
.. code-block:: ini
|
||||||
|
|
||||||
|
[authentication]
|
||||||
|
type=oauth
|
||||||
|
provider=gitlab
|
||||||
|
client_id=<your-client-id>
|
||||||
|
client_secret=<your-client-secret>
|
||||||
|
redirect_uri=https://backup.example.com/callback
|
||||||
|
gitlab_url=https://gitlab.example.com
|
||||||
|
|
||||||
|
Generic OAuth Provider
|
||||||
|
++++++++++++++++++++++
|
||||||
|
|
||||||
|
For custom OAuth providers:
|
||||||
|
|
||||||
|
.. code-block:: ini
|
||||||
|
|
||||||
|
[authentication]
|
||||||
|
type=oauth
|
||||||
|
provider=generic
|
||||||
|
client_id=<your-client-id>
|
||||||
|
client_secret=<your-client-secret>
|
||||||
|
redirect_uri=https://backup.example.com/callback
|
||||||
|
authorize_url=https://provider.example.com/oauth/authorize
|
||||||
|
token_url=https://provider.example.com/oauth/token
|
||||||
|
userinfo_url=https://provider.example.com/oauth/userinfo
|
||||||
|
|
||||||
|
Advanced Configuration
|
||||||
|
----------------------
|
||||||
|
|
||||||
|
Multiple Authentication Methods
|
||||||
|
++++++++++++++++++++++++++++++++
|
||||||
|
|
||||||
|
You can only use one authentication method at a time. To switch methods,
|
||||||
|
update the ``type`` parameter in the configuration file and restart
|
||||||
|
the TISBackup GUI service.
|
||||||
|
|
||||||
|
Disabling Authentication (Not Recommended)
|
||||||
|
++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
|
||||||
|
.. warning::
|
||||||
|
|
||||||
|
Disabling authentication is **not recommended** for production environments.
|
||||||
|
Only use this for testing or when the web interface is protected by other means
|
||||||
|
(e.g., VPN, firewall rules).
|
||||||
|
|
||||||
|
To disable authentication:
|
||||||
|
|
||||||
|
.. code-block:: ini
|
||||||
|
|
||||||
|
[authentication]
|
||||||
|
type=none
|
||||||
|
|
||||||
|
Custom Realm
|
||||||
|
++++++++++++
|
||||||
|
|
||||||
|
For Basic Authentication, customize the authentication realm:
|
||||||
|
|
||||||
|
.. code-block:: ini
|
||||||
|
|
||||||
|
[authentication]
|
||||||
|
type=basic
|
||||||
|
realm=My Company Backup System
|
||||||
|
|
||||||
|
Session Timeout
|
||||||
|
+++++++++++++++
|
||||||
|
|
||||||
|
For Flask-Login and OAuth, configure session timeout (in seconds):
|
||||||
|
|
||||||
|
.. code-block:: ini
|
||||||
|
|
||||||
|
[authentication]
|
||||||
|
type=flask-login
|
||||||
|
session_timeout=7200 # 2 hours
|
||||||
|
|
||||||
|
Troubleshooting
|
||||||
|
---------------
|
||||||
|
|
||||||
|
Authentication Not Working
|
||||||
|
++++++++++++++++++++++++++
|
||||||
|
|
||||||
|
Check the logs for authentication errors:
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
journalctl -u tisbackup_gui -n 100
|
||||||
|
|
||||||
|
Verify configuration file syntax:
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
python3 -c "from configparser import ConfigParser; cp = ConfigParser(); cp.read('/etc/tis/tisbackup_gui.ini'); print('OK')"
|
||||||
|
|
||||||
|
Random Password Generated
|
||||||
|
++++++++++++++++++++++++++
|
||||||
|
|
||||||
|
If you see a warning about a generated password in the logs:
|
||||||
|
|
||||||
|
.. code-block:: text
|
||||||
|
|
||||||
|
WARNING: Generated temporary password for 'admin': abc123xyz
|
||||||
|
|
||||||
|
This means no password was configured. Set ``TISBACKUP_AUTH_PASSWORD`` environment
|
||||||
|
variable or add an ``[authentication]`` section to the configuration file.
|
||||||
|
|
||||||
|
OAuth Callback Error
|
||||||
|
++++++++++++++++++++
|
||||||
|
|
||||||
|
Ensure the redirect URI in your OAuth provider configuration **exactly matches**
|
||||||
|
the ``redirect_uri`` parameter in the TISBackup configuration.
|
||||||
|
|
||||||
|
The redirect URI should be: ``https://your-domain.com/callback``
|
||||||
|
|
||||||
|
User File Not Found
|
||||||
|
+++++++++++++++++++
|
||||||
|
|
||||||
|
For Flask-Login authentication, ensure the user file exists and has proper permissions:
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
ls -l /etc/tis/tisbackup_users.txt
|
||||||
|
# Should show: -rw------- 1 root root ...
|
||||||
|
|
||||||
|
Security Recommendations
|
||||||
|
------------------------
|
||||||
|
|
||||||
|
1. **Use HTTPS**: Always use HTTPS in production (configure via reverse proxy)
|
||||||
|
2. **Strong Passwords**: Use long, random passwords or password hashes
|
||||||
|
3. **Restrict Access**: Use firewall rules to limit access to trusted networks
|
||||||
|
4. **Regular Updates**: Keep authentication dependencies updated
|
||||||
|
5. **Monitor Logs**: Regularly check logs for failed authentication attempts
|
||||||
|
6. **Session Security**: Use short session timeouts for sensitive environments
|
||||||
|
|
||||||
|
For more security best practices, see the **Security Best Practices** section of the documentation.
|
||||||
|
|
||||||
|
Migration Guide
|
||||||
|
---------------
|
||||||
|
|
||||||
|
From No Authentication
|
||||||
|
++++++++++++++++++++++
|
||||||
|
|
||||||
|
If upgrading from a version without authentication:
|
||||||
|
|
||||||
|
1. Add authentication configuration as described above
|
||||||
|
2. Restart the TISBackup GUI service
|
||||||
|
3. Update any automated tools to include authentication credentials
|
||||||
|
|
||||||
|
From Basic to OAuth
|
||||||
|
+++++++++++++++++++
|
||||||
|
|
||||||
|
1. Set up OAuth provider configuration
|
||||||
|
2. Update ``type=oauth`` in configuration file
|
||||||
|
3. Install required dependencies: ``uv pip install authlib requests``
|
||||||
|
4. Restart the service
|
||||||
|
5. Test login with OAuth provider
|
||||||
|
|
||||||
|
Additional Resources
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
For comprehensive authentication setup examples and troubleshooting,
|
||||||
|
see the :file:`AUTHENTICATION.md` file in the TISBackup repository root.
|
||||||
@@ -30,50 +30,51 @@
|
|||||||
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
||||||
# ones.
|
# ones.
|
||||||
extensions = [
|
extensions = [
|
||||||
'sphinx.ext.doctest',
|
"sphinx.ext.doctest",
|
||||||
'sphinx.ext.intersphinx',
|
"sphinx.ext.intersphinx",
|
||||||
'sphinx.ext.todo',
|
"sphinx.ext.todo",
|
||||||
'sphinx.ext.viewcode',
|
"sphinx.ext.viewcode",
|
||||||
'sphinx.ext.githubpages',
|
"sphinx.ext.githubpages",
|
||||||
|
"sphinx_tabs.tabs",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Add any paths that contain templates here, relative to this directory.
|
# Add any paths that contain templates here, relative to this directory.
|
||||||
templates_path = ['_templates']
|
templates_path = ["_templates"]
|
||||||
|
|
||||||
# The suffix(es) of source filenames.
|
# The suffix(es) of source filenames.
|
||||||
# You can specify multiple suffix as a list of string:
|
# You can specify multiple suffix as a list of string:
|
||||||
#
|
#
|
||||||
# source_suffix = ['.rst', '.md']
|
# source_suffix = ['.rst', '.md']
|
||||||
source_suffix = '.rst'
|
source_suffix = ".rst"
|
||||||
|
|
||||||
# The encoding of source files.
|
# The encoding of source files.
|
||||||
#
|
#
|
||||||
# source_encoding = 'utf-8-sig'
|
# source_encoding = 'utf-8-sig'
|
||||||
|
|
||||||
# The master toctree document.
|
# The master toctree document.
|
||||||
master_doc = 'index'
|
master_doc = "index"
|
||||||
|
|
||||||
# General information about the project.
|
# General information about the project.
|
||||||
project = 'TISBackup'
|
project = "TISBackup"
|
||||||
copyright = '2020, Tranquil IT'
|
copyright = "2020, Tranquil IT"
|
||||||
author = 'Tranquil IT'
|
author = "Tranquil IT"
|
||||||
|
|
||||||
# The version info for the project you're documenting, acts as replacement for
|
# The version info for the project you're documenting, acts as replacement for
|
||||||
# |version| and |release|, also used in various other places throughout the
|
# |version| and |release|, also used in various other places throughout the
|
||||||
# built documents.
|
# built documents.
|
||||||
#
|
#
|
||||||
# The short X.Y version.
|
# The short X.Y version.
|
||||||
version = '1.8'
|
version = "1.8"
|
||||||
# The full version, including alpha/beta/rc tags.
|
# The full version, including alpha/beta/rc tags.
|
||||||
release = '1.8.2'
|
release = "1.8.2"
|
||||||
|
|
||||||
# The language for content autogenerated by Sphinx. Refer to documentation
|
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||||
# for a list of supported languages.
|
# for a list of supported languages.
|
||||||
#
|
#
|
||||||
# This is also used if you do content translation via gettext catalogs.
|
# This is also used if you do content translation via gettext catalogs.
|
||||||
# Usually you set "language" from the command line for these cases.
|
# Usually you set "language" from the command line for these cases.
|
||||||
language = 'en'
|
language = "en"
|
||||||
locale_dirs = ['locale/']
|
locale_dirs = ["locale/"]
|
||||||
gettext_compact = False
|
gettext_compact = False
|
||||||
|
|
||||||
# There are two options for replacing |today|: either, you set today to some
|
# There are two options for replacing |today|: either, you set today to some
|
||||||
@@ -110,7 +111,7 @@ exclude_patterns = []
|
|||||||
# show_authors = False
|
# show_authors = False
|
||||||
|
|
||||||
# The name of the Pygments (syntax highlighting) style to use.
|
# The name of the Pygments (syntax highlighting) style to use.
|
||||||
pygments_style = 'sphinx'
|
pygments_style = "sphinx"
|
||||||
|
|
||||||
# A list of ignored prefixes for module index sorting.
|
# A list of ignored prefixes for module index sorting.
|
||||||
# modindex_common_prefix = []
|
# modindex_common_prefix = []
|
||||||
@@ -124,21 +125,9 @@ todo_include_todos = True
|
|||||||
|
|
||||||
# -- Options for HTML output ----------------------------------------------
|
# -- Options for HTML output ----------------------------------------------
|
||||||
|
|
||||||
try:
|
html_theme = "alabaster"
|
||||||
import sphinx_rtd_theme
|
html_theme_path = []
|
||||||
html_theme = "sphinx_rtd_theme"
|
html_favicon = "_static/favicon.ico"
|
||||||
html_favicon = "_static/favicon.ico"
|
|
||||||
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
|
|
||||||
html_context = {
|
|
||||||
'css_files': [
|
|
||||||
'_static/css/custom.css', # overrides for wide tables in RTD theme
|
|
||||||
'_static/css/ribbon.css',
|
|
||||||
'_static/theme_overrides.css', # override wide tables in RTD theme
|
|
||||||
],
|
|
||||||
}
|
|
||||||
except ImportError as e:
|
|
||||||
html_theme = 'alabaster'
|
|
||||||
html_theme_path = []
|
|
||||||
|
|
||||||
|
|
||||||
# The theme to use for HTML and HTML Help pages. See the documentation for
|
# The theme to use for HTML and HTML Help pages. See the documentation for
|
||||||
@@ -178,7 +167,7 @@ except ImportError as e:
|
|||||||
# Add any paths that contain custom static files (such as style sheets) here,
|
# Add any paths that contain custom static files (such as style sheets) here,
|
||||||
# relative to this directory. They are copied after the builtin static files,
|
# relative to this directory. They are copied after the builtin static files,
|
||||||
# so a file named "default.css" will overwrite the builtin "default.css".
|
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||||
html_static_path = ['_static']
|
html_static_path = ["_static"]
|
||||||
|
|
||||||
# Add any extra paths that contain custom files (such as robots.txt or
|
# Add any extra paths that contain custom files (such as robots.txt or
|
||||||
# .htaccess) here, relative to this directory. These files are copied
|
# .htaccess) here, relative to this directory. These files are copied
|
||||||
@@ -258,15 +247,13 @@ html_static_path = ['_static']
|
|||||||
# html_search_scorer = 'scorer.js'
|
# html_search_scorer = 'scorer.js'
|
||||||
|
|
||||||
# Output file base name for HTML help builder.
|
# Output file base name for HTML help builder.
|
||||||
htmlhelp_basename = 'tisbackupdoc'
|
htmlhelp_basename = "tisbackupdoc"
|
||||||
|
|
||||||
# -- Linkcheck -------------------
|
# -- Linkcheck -------------------
|
||||||
# make linkcheck
|
# make linkcheck
|
||||||
# URL patterns to ignore
|
# URL patterns to ignore
|
||||||
|
|
||||||
linkcheck_ignore = [r'http.*://.*mydomain.lan.*',
|
linkcheck_ignore = [r"http.*://.*mydomain.lan.*", r"http.*://.*host_fqdn.*", r"http://user:pwd@host_fqdn:port"]
|
||||||
r'http.*://.*host_fqdn.*',
|
|
||||||
r'http://user:pwd@host_fqdn:port']
|
|
||||||
|
|
||||||
|
|
||||||
# -- Options for LaTeX output ---------------------------------------------
|
# -- Options for LaTeX output ---------------------------------------------
|
||||||
@@ -279,23 +266,20 @@ linkcheck_ignore = [r'http.*://.*mydomain.lan.*',
|
|||||||
# > \setlength\paperwidth {15.59cm}}
|
# > \setlength\paperwidth {15.59cm}}
|
||||||
|
|
||||||
latex_elements = {
|
latex_elements = {
|
||||||
# The paper size ('letterpaper' or 'a4paper').
|
# The paper size ('letterpaper' or 'a4paper').
|
||||||
#
|
#
|
||||||
# 'papersize': 'letterpaper',
|
# 'papersize': 'letterpaper',
|
||||||
'papersize': 'lulupaper',
|
"papersize": "lulupaper",
|
||||||
|
# The font size ('10pt', '11pt' or '12pt').
|
||||||
# The font size ('10pt', '11pt' or '12pt').
|
#
|
||||||
#
|
"pointsize": "9pt",
|
||||||
'pointsize': '9pt',
|
# Additional stuff for the LaTeX preamble.
|
||||||
|
#
|
||||||
# Additional stuff for the LaTeX preamble.
|
"preamble": r"\batchmode",
|
||||||
#
|
# Latex figure (float) alignment
|
||||||
'preamble': r'\batchmode',
|
#
|
||||||
|
# 'figure_align': 'htbp',
|
||||||
# Latex figure (float) alignment
|
"sphinxsetup": "hmargin={1.5cm,1.5cm}, vmargin={3cm,3cm}, marginpar=1cm",
|
||||||
#
|
|
||||||
# 'figure_align': 'htbp',
|
|
||||||
'sphinxsetup': 'hmargin={1.5cm,1.5cm}, vmargin={3cm,3cm}, marginpar=1cm',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -303,7 +287,7 @@ latex_elements = {
|
|||||||
# (source start file, target name, title,
|
# (source start file, target name, title,
|
||||||
# author, documentclass [howto, manual, or own class]).
|
# author, documentclass [howto, manual, or own class]).
|
||||||
latex_documents = [
|
latex_documents = [
|
||||||
(master_doc, 'tisbackup.tex', 'TISBackup Documentation', 'Tranquil IT', 'manual'),
|
(master_doc, "tisbackup.tex", "TISBackup Documentation", "Tranquil IT", "manual"),
|
||||||
]
|
]
|
||||||
|
|
||||||
# The name of an image file (relative to this directory) to place at the top of
|
# The name of an image file (relative to this directory) to place at the top of
|
||||||
@@ -343,10 +327,7 @@ latex_documents = [
|
|||||||
|
|
||||||
# One entry per manual page. List of tuples
|
# One entry per manual page. List of tuples
|
||||||
# (source start file, name, description, authors, manual section).
|
# (source start file, name, description, authors, manual section).
|
||||||
man_pages = [
|
man_pages = [(master_doc, "tisbackup", "TISBackup Documentation", [author], 1)]
|
||||||
(master_doc, 'tisbackup', 'TISBackup Documentation',
|
|
||||||
[author], 1)
|
|
||||||
]
|
|
||||||
|
|
||||||
# If true, show URL addresses after external links.
|
# If true, show URL addresses after external links.
|
||||||
#
|
#
|
||||||
@@ -359,9 +340,15 @@ man_pages = [
|
|||||||
# (source start file, target name, title, author,
|
# (source start file, target name, title, author,
|
||||||
# dir menu entry, description, category)
|
# dir menu entry, description, category)
|
||||||
texinfo_documents = [
|
texinfo_documents = [
|
||||||
(master_doc, 'tisbackup', 'TISBackup Documentation',
|
(
|
||||||
author, 'Tranquil IT', 'The objective of TISbackup is to benefit from file backups and centralized alert feedback on "reasonable" data volumes.',
|
master_doc,
|
||||||
'Miscellaneous'),
|
"tisbackup",
|
||||||
|
"TISBackup Documentation",
|
||||||
|
author,
|
||||||
|
"Tranquil IT",
|
||||||
|
'The objective of TISbackup is to benefit from file backups and centralized alert feedback on "reasonable" data volumes.',
|
||||||
|
"Miscellaneous",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Documents to append as an appendix to all manuals.
|
# Documents to append as an appendix to all manuals.
|
||||||
@@ -382,7 +369,9 @@ texinfo_documents = [
|
|||||||
|
|
||||||
|
|
||||||
# Example configuration for intersphinx: refer to the Python standard library.
|
# Example configuration for intersphinx: refer to the Python standard library.
|
||||||
intersphinx_mapping = {'https://docs.python.org/': None}
|
intersphinx_mapping = {
|
||||||
|
"python": ("https://docs.python.org/3", None),
|
||||||
|
}
|
||||||
|
|
||||||
# -- Options for Epub output ----------------------------------------------
|
# -- Options for Epub output ----------------------------------------------
|
||||||
|
|
||||||
@@ -438,7 +427,7 @@ epub_copyright = copyright
|
|||||||
# epub_post_files = []
|
# epub_post_files = []
|
||||||
|
|
||||||
# A list of files that should not be packed into the epub file.
|
# A list of files that should not be packed into the epub file.
|
||||||
epub_exclude_files = ['search.html']
|
epub_exclude_files = ["search.html"]
|
||||||
|
|
||||||
# The depth of the table of contents in toc.ncx.
|
# The depth of the table of contents in toc.ncx.
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ Backing up a MySQL database
|
|||||||
[srvintranet_mysql_mediawiki]
|
[srvintranet_mysql_mediawiki]
|
||||||
type=mysql+ssh
|
type=mysql+ssh
|
||||||
server_name=srvintranet
|
server_name=srvintranet
|
||||||
private_key=/root/.ssh/id_dsa
|
private_key=/root/.ssh/id_ed25519
|
||||||
db_name=mediawiki
|
db_name=mediawiki
|
||||||
db_user=user
|
db_user=user
|
||||||
db_passwd=password
|
db_passwd=password
|
||||||
@@ -141,7 +141,7 @@ Backing up a file server
|
|||||||
type=rsync+ssh
|
type=rsync+ssh
|
||||||
server_name=srvfiles
|
server_name=srvfiles
|
||||||
remote_dir=/home
|
remote_dir=/home
|
||||||
private_key=/root/.ssh/id_dsa
|
private_key=/root/.ssh/id_ed25519
|
||||||
exclude_list=".mozilla",".thunderbird",".x2go","*.avi"
|
exclude_list=".mozilla",".thunderbird",".x2go","*.avi"
|
||||||
bwlimit = 100
|
bwlimit = 100
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,13 @@ would have been difficult to develop as an overlay of the existing one:
|
|||||||
configuring_tisbackup.rst
|
configuring_tisbackup.rst
|
||||||
using_tisbackup.rst
|
using_tisbackup.rst
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 2
|
||||||
|
:caption: Security & Authentication
|
||||||
|
|
||||||
|
security.rst
|
||||||
|
authentication.rst
|
||||||
|
|
||||||
.. toctree::
|
.. toctree::
|
||||||
:maxdepth: 1
|
:maxdepth: 1
|
||||||
:caption: Appendix
|
:caption: Appendix
|
||||||
|
|||||||
@@ -251,14 +251,24 @@ Launching the backup scheduled task
|
|||||||
Generating the public and private certificates
|
Generating the public and private certificates
|
||||||
++++++++++++++++++++++++++++++++++++++++++++++
|
++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
|
||||||
* as root:
|
* as root, generate an Ed25519 SSH key (modern and secure algorithm):
|
||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
ssh-keygen -t rsa -b 2048
|
ssh-keygen -t ed25519 -C "tisbackup@$(hostname)"
|
||||||
|
|
||||||
* press :kbd:`Enter` for each one of the steps;
|
* press :kbd:`Enter` for each one of the steps;
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
TISBackup supports Ed25519, ECDSA, and RSA key algorithms (in order of preference).
|
||||||
|
DSA keys are no longer supported for security reasons. If you need RSA for compatibility,
|
||||||
|
use at least 4096 bits:
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
ssh-keygen -t rsa -b 4096 -C "tisbackup@$(hostname)"
|
||||||
|
|
||||||
|clap| You may now go on to the next step
|
|clap| You may now go on to the next step
|
||||||
and :ref:`configure the backup jobs for your TISBackup<configuring_backup_jobs>`.
|
and :ref:`configure the backup jobs for your TISBackup<configuring_backup_jobs>`.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
.. Reminder for header structure:
|
||||||
|
Level 1: ====================
|
||||||
|
Level 2: --------------------
|
||||||
|
Level 3: ++++++++++++++++++++
|
||||||
|
Level 4: """"""""""""""""""""
|
||||||
|
Level 5: ^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
.. meta::
|
||||||
|
:description: Security best practices for TISBackup
|
||||||
|
:keywords: Documentation, TISBackup, security, best practices, authentication
|
||||||
|
|
||||||
|
Security Best Practices
|
||||||
|
=======================
|
||||||
|
|
||||||
|
.. _security_best_practices:
|
||||||
|
|
||||||
|
TISBackup has been designed with security in mind. This section outlines
|
||||||
|
the security features and best practices for deploying and maintaining
|
||||||
|
a secure backup infrastructure.
|
||||||
|
|
||||||
|
SSH Key Algorithm Support
|
||||||
|
--------------------------
|
||||||
|
|
||||||
|
Modern SSH Key Algorithms
|
||||||
|
+++++++++++++++++++++++++
|
||||||
|
|
||||||
|
TISBackup supports modern SSH key algorithms with the following priority:
|
||||||
|
|
||||||
|
1. **Ed25519** (recommended) - Modern, fast, and secure
|
||||||
|
2. **ECDSA** - Elliptic curve cryptography
|
||||||
|
3. **RSA** - Traditional algorithm (use 4096 bits minimum)
|
||||||
|
|
||||||
|
.. warning::
|
||||||
|
|
||||||
|
DSA keys are **no longer supported** due to known security vulnerabilities.
|
||||||
|
If you are using DSA keys, you must migrate to Ed25519, ECDSA, or RSA.
|
||||||
|
|
||||||
|
Generating Secure SSH Keys
|
||||||
|
+++++++++++++++++++++++++++
|
||||||
|
|
||||||
|
For new installations, generate an Ed25519 key:
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
ssh-keygen -t ed25519 -C "tisbackup@$(hostname)"
|
||||||
|
|
||||||
|
For compatibility with older systems that don't support Ed25519, use RSA with 4096 bits:
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
ssh-keygen -t rsa -b 4096 -C "tisbackup@$(hostname)"
|
||||||
|
|
||||||
|
Migrating from DSA Keys
|
||||||
|
++++++++++++++++++++++++
|
||||||
|
|
||||||
|
If you have existing backup configurations using DSA keys:
|
||||||
|
|
||||||
|
1. Generate a new Ed25519 key on the backup server
|
||||||
|
2. Copy the new public key to all backup clients
|
||||||
|
3. Update the ``private_key`` parameter in all backup sections
|
||||||
|
4. Test the backups to ensure they work with the new key
|
||||||
|
5. Remove the old DSA keys from both server and clients
|
||||||
|
|
||||||
|
Flask Web Interface Security
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
Authentication
|
||||||
|
++++++++++++++
|
||||||
|
|
||||||
|
The Flask web interface now requires authentication by default.
|
||||||
|
TISBackup supports multiple authentication methods:
|
||||||
|
|
||||||
|
Basic Authentication (Default)
|
||||||
|
"""""""""""""""""""""""""""""""
|
||||||
|
|
||||||
|
By default, TISBackup uses HTTP Basic Authentication. Configure it via
|
||||||
|
environment variables or the configuration file.
|
||||||
|
|
||||||
|
**Environment variables:**
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
export TISBACKUP_AUTH_USERNAME="admin"
|
||||||
|
export TISBACKUP_AUTH_PASSWORD="your-secure-password"
|
||||||
|
|
||||||
|
**Configuration file** (:file:`/etc/tis/tisbackup_gui.ini`):
|
||||||
|
|
||||||
|
.. code-block:: ini
|
||||||
|
|
||||||
|
[authentication]
|
||||||
|
type=basic
|
||||||
|
username=admin
|
||||||
|
# Bcrypt hash of password (recommended)
|
||||||
|
password_hash=$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5eSZL9fJQp.Ym
|
||||||
|
use_bcrypt=True
|
||||||
|
realm=TISBackup
|
||||||
|
|
||||||
|
.. warning::
|
||||||
|
|
||||||
|
If no password is configured, TISBackup will generate a random password
|
||||||
|
and display it in the logs. This is not suitable for production use.
|
||||||
|
|
||||||
|
Session-Based Authentication (Flask-Login)
|
||||||
|
"""""""""""""""""""""""""""""""""""""""""""
|
||||||
|
|
||||||
|
For more advanced deployments, you can use Flask-Login with a user file:
|
||||||
|
|
||||||
|
.. code-block:: ini
|
||||||
|
|
||||||
|
[authentication]
|
||||||
|
type=flask-login
|
||||||
|
user_file=/etc/tis/tisbackup_users.txt
|
||||||
|
secret_key=<random-secret-key>
|
||||||
|
|
||||||
|
OAuth2 Authentication
|
||||||
|
""""""""""""""""""""""
|
||||||
|
|
||||||
|
For enterprise deployments, OAuth2 is supported with providers like Google,
|
||||||
|
GitHub, and GitLab:
|
||||||
|
|
||||||
|
.. code-block:: ini
|
||||||
|
|
||||||
|
[authentication]
|
||||||
|
type=oauth
|
||||||
|
provider=google
|
||||||
|
client_id=<your-client-id>
|
||||||
|
client_secret=<your-client-secret>
|
||||||
|
redirect_uri=http://backup.example.com:8080/callback
|
||||||
|
allowed_domains=example.com
|
||||||
|
|
||||||
|
See :file:`AUTHENTICATION.md` in the repository root for detailed
|
||||||
|
authentication configuration.
|
||||||
|
|
||||||
|
Secret Key Configuration
|
||||||
|
+++++++++++++++++++++++++
|
||||||
|
|
||||||
|
The Flask application requires a secret key for session security.
|
||||||
|
|
||||||
|
**Never use the default hardcoded key in production!**
|
||||||
|
|
||||||
|
Configure via environment variable:
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
export TISBACKUP_SECRET_KEY="your-random-secret-key-here"
|
||||||
|
|
||||||
|
Or in :file:`/etc/tis/tisbackup_gui.ini`:
|
||||||
|
|
||||||
|
.. code-block:: ini
|
||||||
|
|
||||||
|
[global]
|
||||||
|
secret_key=your-random-secret-key-here
|
||||||
|
|
||||||
|
Generate a secure random key:
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
|
||||||
|
SSL/TLS Configuration
|
||||||
|
+++++++++++++++++++++
|
||||||
|
|
||||||
|
For production deployments, always use HTTPS. Place the Flask application
|
||||||
|
behind a reverse proxy like Nginx or Apache:
|
||||||
|
|
||||||
|
**Nginx example:**
|
||||||
|
|
||||||
|
.. code-block:: nginx
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name backup.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/ssl/certs/backup.crt;
|
||||||
|
ssl_certificate_key /etc/ssl/private/backup.key;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Database and Backup Security
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
File Permissions
|
||||||
|
++++++++++++++++
|
||||||
|
|
||||||
|
Ensure proper file permissions on sensitive files:
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
# Configuration files
|
||||||
|
chmod 600 /etc/tis/tisbackup-config.ini
|
||||||
|
chmod 600 /etc/tis/tisbackup_gui.ini
|
||||||
|
|
||||||
|
# SSH keys
|
||||||
|
chmod 600 /root/.ssh/id_ed25519
|
||||||
|
chmod 644 /root/.ssh/id_ed25519.pub
|
||||||
|
|
||||||
|
# Password files (for XenServer, etc.)
|
||||||
|
chmod 600 /root/xen_passwd
|
||||||
|
|
||||||
|
# Backup directory
|
||||||
|
chown -R root:root /backup/data
|
||||||
|
chmod 750 /backup/data
|
||||||
|
|
||||||
|
Credential Storage
|
||||||
|
++++++++++++++++++
|
||||||
|
|
||||||
|
For database credentials and other secrets:
|
||||||
|
|
||||||
|
* Use strong, unique passwords for each service
|
||||||
|
* Store credentials in configuration files with restricted permissions
|
||||||
|
* Consider using a secrets management system for sensitive deployments
|
||||||
|
* Rotate credentials regularly
|
||||||
|
|
||||||
|
Network Security
|
||||||
|
++++++++++++++++
|
||||||
|
|
||||||
|
* Restrict SSH access to the backup server IP address
|
||||||
|
* Use firewall rules to limit access to the web interface
|
||||||
|
* Consider VPN access for remote backup management
|
||||||
|
* Enable fail2ban or similar tools to prevent brute-force attacks
|
||||||
|
|
||||||
|
Security Monitoring
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
Log Monitoring
|
||||||
|
++++++++++++++
|
||||||
|
|
||||||
|
Regularly review TISBackup logs for:
|
||||||
|
|
||||||
|
* Failed authentication attempts
|
||||||
|
* Backup failures or timeouts
|
||||||
|
* Unusual activity patterns
|
||||||
|
* SSH connection errors
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
# View recent backup logs
|
||||||
|
journalctl -u tisbackup_gui -n 100
|
||||||
|
|
||||||
|
# Monitor for authentication failures
|
||||||
|
grep "authentication failed" /var/log/tisbackup/*.log
|
||||||
|
|
||||||
|
Backup Verification
|
||||||
|
+++++++++++++++++++
|
||||||
|
|
||||||
|
* Regularly test backup restoration
|
||||||
|
* Verify backup integrity using checksums
|
||||||
|
* Monitor backup sizes for unexpected changes
|
||||||
|
* Set up Nagios checks for backup freshness
|
||||||
|
|
||||||
|
Security Updates
|
||||||
|
++++++++++++++++
|
||||||
|
|
||||||
|
* Keep TISBackup updated to the latest version
|
||||||
|
* Apply security patches to the host operating system
|
||||||
|
* Update Python dependencies regularly:
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
uv sync --upgrade
|
||||||
|
|
||||||
|
Additional Security Recommendations
|
||||||
|
------------------------------------
|
||||||
|
|
||||||
|
1. **Principle of Least Privilege**: Create dedicated service accounts
|
||||||
|
for backups rather than using root when possible
|
||||||
|
|
||||||
|
2. **Network Segmentation**: Place the backup server in a dedicated
|
||||||
|
network segment with restricted access
|
||||||
|
|
||||||
|
3. **Backup Encryption**: Consider encrypting backups at rest,
|
||||||
|
especially for sensitive data
|
||||||
|
|
||||||
|
4. **Off-site Storage**: Maintain encrypted off-site backups
|
||||||
|
for disaster recovery
|
||||||
|
|
||||||
|
5. **Access Auditing**: Maintain logs of who accesses backups
|
||||||
|
and when they are restored
|
||||||
|
|
||||||
|
6. **Incident Response**: Have a documented procedure for responding
|
||||||
|
to security incidents involving the backup infrastructure
|
||||||
Vendored
-2
@@ -293,5 +293,3 @@ function splitQuery(query) {
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Executable
+7
@@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
env >> /etc/environment
|
||||||
|
|
||||||
|
# execute CMD
|
||||||
|
echo "$@"
|
||||||
|
exec "$@"
|
||||||
@@ -16,3 +16,76 @@
|
|||||||
#
|
#
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
"""
|
||||||
|
TISBackup library - Backup orchestration and driver management.
|
||||||
|
|
||||||
|
This package provides a modular backup system with:
|
||||||
|
- Base driver classes for implementing backup types
|
||||||
|
- Database management for backup statistics
|
||||||
|
- SSH and process execution utilities
|
||||||
|
- Date/time and formatting helpers
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Import from new modular structure
|
||||||
|
from .base_driver import (
|
||||||
|
backup_drivers,
|
||||||
|
backup_generic,
|
||||||
|
nagiosStateCritical,
|
||||||
|
nagiosStateOk,
|
||||||
|
nagiosStateUnknown,
|
||||||
|
nagiosStateWarning,
|
||||||
|
register_driver,
|
||||||
|
)
|
||||||
|
from .database import BackupStat
|
||||||
|
from .process import call_external_process, monitor_stdout
|
||||||
|
from .ssh import load_ssh_private_key, ssh_exec
|
||||||
|
from .utils import (
|
||||||
|
check_string,
|
||||||
|
convert_bytes,
|
||||||
|
dateof,
|
||||||
|
datetime2isodate,
|
||||||
|
fileisodate,
|
||||||
|
hours_minutes,
|
||||||
|
html_table,
|
||||||
|
isodate2datetime,
|
||||||
|
pp,
|
||||||
|
splitThousands,
|
||||||
|
str2bool,
|
||||||
|
time2display,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Maintain backward compatibility - re-export everything that was in common.py
|
||||||
|
__all__ = [
|
||||||
|
# Nagios states
|
||||||
|
"nagiosStateOk",
|
||||||
|
"nagiosStateWarning",
|
||||||
|
"nagiosStateCritical",
|
||||||
|
"nagiosStateUnknown",
|
||||||
|
# Driver registry
|
||||||
|
"backup_drivers",
|
||||||
|
"register_driver",
|
||||||
|
# Base classes
|
||||||
|
"backup_generic",
|
||||||
|
"BackupStat",
|
||||||
|
# SSH utilities
|
||||||
|
"load_ssh_private_key",
|
||||||
|
"ssh_exec",
|
||||||
|
# Process utilities
|
||||||
|
"call_external_process",
|
||||||
|
"monitor_stdout",
|
||||||
|
# Date/time utilities
|
||||||
|
"datetime2isodate",
|
||||||
|
"isodate2datetime",
|
||||||
|
"time2display",
|
||||||
|
"hours_minutes",
|
||||||
|
"fileisodate",
|
||||||
|
"dateof",
|
||||||
|
# Formatting utilities
|
||||||
|
"splitThousands",
|
||||||
|
"convert_bytes",
|
||||||
|
"pp",
|
||||||
|
"html_table",
|
||||||
|
# Validation utilities
|
||||||
|
"check_string",
|
||||||
|
"str2bool",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
# TISBackup Authentication Module
|
||||||
|
|
||||||
|
Pluggable authentication system for Flask routes.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Multiple providers**: Basic Auth, Flask-Login, OAuth2
|
||||||
|
- **Easy integration**: Simple decorator-based protection
|
||||||
|
- **Configurable**: INI-based configuration
|
||||||
|
- **Secure**: bcrypt password hashing, OAuth integration
|
||||||
|
- **Extensible**: Easy to add new providers
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Choose Authentication Provider
|
||||||
|
|
||||||
|
```python
|
||||||
|
from libtisbackup.auth import get_auth_provider
|
||||||
|
|
||||||
|
# Get provider from config
|
||||||
|
auth = get_auth_provider("basic", {
|
||||||
|
"username": "admin",
|
||||||
|
"password": "$2b$12$...", # bcrypt hash
|
||||||
|
"use_bcrypt": True
|
||||||
|
})
|
||||||
|
|
||||||
|
# Initialize with Flask app
|
||||||
|
auth.init_app(app)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Protect Routes
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.route("/")
|
||||||
|
@auth.require_auth
|
||||||
|
def index():
|
||||||
|
user = auth.get_current_user()
|
||||||
|
return f"Hello {user['username']}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Providers
|
||||||
|
|
||||||
|
### Base Provider (No Auth)
|
||||||
|
|
||||||
|
```python
|
||||||
|
auth = get_auth_provider("none", {})
|
||||||
|
```
|
||||||
|
|
||||||
|
- No authentication required
|
||||||
|
- All routes publicly accessible
|
||||||
|
- Useful for development/testing
|
||||||
|
|
||||||
|
### Basic Auth
|
||||||
|
|
||||||
|
```python
|
||||||
|
auth = get_auth_provider("basic", {
|
||||||
|
"username": "admin",
|
||||||
|
"password": "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYWv.5qVQK6",
|
||||||
|
"use_bcrypt": True,
|
||||||
|
"realm": "TISBackup"
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- HTTP Basic Authentication
|
||||||
|
- bcrypt password hashing
|
||||||
|
- Custom realm support
|
||||||
|
|
||||||
|
### Flask-Login
|
||||||
|
|
||||||
|
```python
|
||||||
|
auth = get_auth_provider("flask-login", {
|
||||||
|
"users_file": "/etc/tis/users.txt",
|
||||||
|
"use_bcrypt": True,
|
||||||
|
"login_view": "login"
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Session-based authentication
|
||||||
|
- Multiple users support
|
||||||
|
- Login/logout pages
|
||||||
|
- bcrypt password hashing
|
||||||
|
|
||||||
|
**User file format** (`users.txt`):
|
||||||
|
```
|
||||||
|
username:bcrypt_hash
|
||||||
|
admin:$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYWv.5qVQK6
|
||||||
|
```
|
||||||
|
|
||||||
|
### OAuth2
|
||||||
|
|
||||||
|
```python
|
||||||
|
auth = get_auth_provider("oauth", {
|
||||||
|
"provider": "google", # or "github", "gitlab", "generic"
|
||||||
|
"client_id": "your-client-id",
|
||||||
|
"client_secret": "your-client-secret",
|
||||||
|
"redirect_uri": "http://localhost:8080/oauth/callback",
|
||||||
|
"authorized_domains": ["example.com"],
|
||||||
|
"authorized_users": ["admin@example.com"]
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- OAuth2 authentication
|
||||||
|
- Google, GitHub, GitLab support
|
||||||
|
- Custom OAuth providers
|
||||||
|
- Domain/user restrictions
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### AuthProvider
|
||||||
|
|
||||||
|
Base class for all auth providers.
|
||||||
|
|
||||||
|
#### Methods
|
||||||
|
|
||||||
|
- `init_app(app)` - Initialize with Flask app
|
||||||
|
- `require_auth(f)` - Decorator to protect routes
|
||||||
|
- `is_authenticated()` - Check if current request is authenticated
|
||||||
|
- `get_current_user()` - Get current user info
|
||||||
|
- `handle_unauthorized()` - Handle unauthorized access
|
||||||
|
- `logout()` - Logout current user
|
||||||
|
|
||||||
|
### BasicAuthProvider
|
||||||
|
|
||||||
|
HTTP Basic Authentication provider.
|
||||||
|
|
||||||
|
#### Configuration
|
||||||
|
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"username": str, # Required
|
||||||
|
"password": str, # Required (plain or bcrypt hash)
|
||||||
|
"use_bcrypt": bool, # Default: False
|
||||||
|
"realm": str # Default: "TISBackup"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### FlaskLoginProvider
|
||||||
|
|
||||||
|
Session-based authentication provider.
|
||||||
|
|
||||||
|
#### Configuration
|
||||||
|
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"users": dict, # {username: password_hash} or...
|
||||||
|
"users_file": str, # Path to users file
|
||||||
|
"use_bcrypt": bool, # Default: True
|
||||||
|
"login_view": str # Default: "login"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Methods
|
||||||
|
|
||||||
|
- `verify_password(username, password)` - Verify credentials
|
||||||
|
- `login_user(username)` - Login user by username
|
||||||
|
|
||||||
|
### OAuthProvider
|
||||||
|
|
||||||
|
OAuth2 authentication provider.
|
||||||
|
|
||||||
|
#### Configuration
|
||||||
|
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"provider": str, # "google", "github", "gitlab", "generic"
|
||||||
|
"client_id": str, # Required
|
||||||
|
"client_secret": str, # Required
|
||||||
|
"redirect_uri": str, # Required
|
||||||
|
"scopes": list, # Optional
|
||||||
|
"authorized_domains": list, # Optional
|
||||||
|
"authorized_users": list, # Optional
|
||||||
|
|
||||||
|
# For generic provider:
|
||||||
|
"authorization_endpoint": str,
|
||||||
|
"token_endpoint": str,
|
||||||
|
"userinfo_endpoint": str
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Methods
|
||||||
|
|
||||||
|
- `is_user_authorized(user_info)` - Check if user is authorized
|
||||||
|
|
||||||
|
## Integration Example
|
||||||
|
|
||||||
|
See [example_integration.py](example_integration.py) for a complete example.
|
||||||
|
|
||||||
|
### Minimal Example
|
||||||
|
|
||||||
|
```python
|
||||||
|
from flask import Flask
|
||||||
|
from libtisbackup.auth import get_auth_provider
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.secret_key = "your-secret-key"
|
||||||
|
|
||||||
|
# Initialize auth
|
||||||
|
auth = get_auth_provider("basic", {
|
||||||
|
"username": "admin",
|
||||||
|
"password": "changeme",
|
||||||
|
"use_bcrypt": False
|
||||||
|
})
|
||||||
|
auth.init_app(app)
|
||||||
|
|
||||||
|
# Protected route
|
||||||
|
@app.route("/")
|
||||||
|
@auth.require_auth
|
||||||
|
def index():
|
||||||
|
return "Protected content"
|
||||||
|
|
||||||
|
# Public route
|
||||||
|
@app.route("/health")
|
||||||
|
def health():
|
||||||
|
return "OK"
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Flask-Login
|
||||||
|
|
||||||
|
```python
|
||||||
|
auth = get_auth_provider("flask-login", {
|
||||||
|
"users": {
|
||||||
|
"admin": "$2b$12$..."
|
||||||
|
},
|
||||||
|
"use_bcrypt": True
|
||||||
|
})
|
||||||
|
auth.init_app(app)
|
||||||
|
|
||||||
|
@app.route("/login", methods=["POST"])
|
||||||
|
def login():
|
||||||
|
username = request.form["username"]
|
||||||
|
password = request.form["password"]
|
||||||
|
|
||||||
|
if auth.verify_password(username, password):
|
||||||
|
auth.login_user(username)
|
||||||
|
return redirect("/")
|
||||||
|
return "Invalid credentials", 401
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
@auth.require_auth
|
||||||
|
def index():
|
||||||
|
user = auth.get_current_user()
|
||||||
|
return f"Hello {user['username']}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### With OAuth
|
||||||
|
|
||||||
|
```python
|
||||||
|
auth = get_auth_provider("oauth", {
|
||||||
|
"provider": "google",
|
||||||
|
"client_id": "...",
|
||||||
|
"client_secret": "...",
|
||||||
|
"redirect_uri": "http://localhost:8080/oauth/callback",
|
||||||
|
"authorized_domains": ["example.com"]
|
||||||
|
})
|
||||||
|
auth.init_app(app)
|
||||||
|
|
||||||
|
@app.route("/oauth/login")
|
||||||
|
def oauth_login():
|
||||||
|
return auth.oauth_client.authorize_redirect(
|
||||||
|
url_for("oauth_callback", _external=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.route("/oauth/callback")
|
||||||
|
def oauth_callback():
|
||||||
|
token = auth.oauth_client.authorize_access_token()
|
||||||
|
user_info = auth.oauth_client.get(auth.userinfo_endpoint).json()
|
||||||
|
|
||||||
|
if auth.is_user_authorized(user_info):
|
||||||
|
session["oauth_user"] = user_info
|
||||||
|
return redirect("/")
|
||||||
|
return "Unauthorized", 403
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **Always use HTTPS in production** - Especially for Basic Auth
|
||||||
|
2. **Use bcrypt for passwords** - Never store plain text passwords
|
||||||
|
3. **Rotate credentials regularly** - Change passwords and OAuth secrets
|
||||||
|
4. **Restrict OAuth access** - Use `authorized_domains` or `authorized_users`
|
||||||
|
5. **Set strong Flask secret_key** - Use `secrets.token_hex(32)`
|
||||||
|
6. **Protect config files** - `chmod 600` for files with credentials
|
||||||
|
7. **Use environment variables** - For sensitive configuration values
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```python
|
||||||
|
import unittest
|
||||||
|
from libtisbackup.auth import get_auth_provider
|
||||||
|
|
||||||
|
class TestBasicAuth(unittest.TestCase):
|
||||||
|
def test_authentication(self):
|
||||||
|
auth = get_auth_provider("basic", {
|
||||||
|
"username": "admin",
|
||||||
|
"password": "test",
|
||||||
|
"use_bcrypt": False
|
||||||
|
})
|
||||||
|
|
||||||
|
# Mock request with credentials
|
||||||
|
# Test authentication logic
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
GPL v3.0 - Same as TISBackup
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
TISBackup Authentication Module
|
||||||
|
|
||||||
|
Provides pluggable authentication providers for Flask routes.
|
||||||
|
Supports: Basic Auth, Flask-Login, OAuth2
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .base import AuthProvider
|
||||||
|
from .basic_auth import BasicAuthProvider
|
||||||
|
from .flask_login_auth import FlaskLoginProvider
|
||||||
|
from .oauth_auth import OAuthProvider
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AuthProvider",
|
||||||
|
"BasicAuthProvider",
|
||||||
|
"FlaskLoginProvider",
|
||||||
|
"OAuthProvider",
|
||||||
|
"get_auth_provider",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_auth_provider(auth_type, config=None):
|
||||||
|
"""Factory function to get authentication provider.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
auth_type: Type of auth ('basic', 'flask-login', 'oauth', or 'none')
|
||||||
|
config: Configuration dict for the provider
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AuthProvider instance
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If auth_type is not supported
|
||||||
|
"""
|
||||||
|
providers = {
|
||||||
|
"none": AuthProvider,
|
||||||
|
"basic": BasicAuthProvider,
|
||||||
|
"flask-login": FlaskLoginProvider,
|
||||||
|
"oauth": OAuthProvider,
|
||||||
|
}
|
||||||
|
|
||||||
|
auth_type = auth_type.lower()
|
||||||
|
if auth_type not in providers:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported auth type: {auth_type}. "
|
||||||
|
f"Supported types: {', '.join(providers.keys())}"
|
||||||
|
)
|
||||||
|
|
||||||
|
provider_class = providers[auth_type]
|
||||||
|
return provider_class(config or {})
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Base authentication provider interface
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
|
||||||
|
class AuthProvider(ABC):
|
||||||
|
"""Base class for authentication providers."""
|
||||||
|
|
||||||
|
def __init__(self, config):
|
||||||
|
"""Initialize the auth provider.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: Dict with provider-specific configuration
|
||||||
|
"""
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
def init_app(self, app):
|
||||||
|
"""Initialize the provider with Flask app.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app: Flask application instance
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def require_auth(self, f):
|
||||||
|
"""Decorator to require authentication for a route.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
f: Flask route function
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decorated function
|
||||||
|
"""
|
||||||
|
@wraps(f)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
if not self.is_authenticated():
|
||||||
|
return self.handle_unauthorized()
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
|
def is_authenticated(self):
|
||||||
|
"""Check if current request is authenticated.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if authenticated, False otherwise
|
||||||
|
"""
|
||||||
|
# Default: no authentication required
|
||||||
|
return True
|
||||||
|
|
||||||
|
def handle_unauthorized(self):
|
||||||
|
"""Handle unauthorized access.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Flask response for unauthorized access
|
||||||
|
"""
|
||||||
|
from flask import jsonify
|
||||||
|
return jsonify({"error": "Unauthorized"}), 401
|
||||||
|
|
||||||
|
def get_current_user(self):
|
||||||
|
"""Get current authenticated user.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User object or dict, or None if not authenticated
|
||||||
|
"""
|
||||||
|
return None
|
||||||
|
|
||||||
|
def logout(self):
|
||||||
|
"""Logout current user."""
|
||||||
|
pass
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
HTTP Basic Authentication provider
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from flask import request
|
||||||
|
|
||||||
|
from .base import AuthProvider
|
||||||
|
|
||||||
|
|
||||||
|
class BasicAuthProvider(AuthProvider):
|
||||||
|
"""HTTP Basic Authentication provider.
|
||||||
|
|
||||||
|
Configuration:
|
||||||
|
username: Required username
|
||||||
|
password: Required password (plain text, or hashed with bcrypt)
|
||||||
|
realm: Authentication realm (default: 'TISBackup')
|
||||||
|
use_bcrypt: If True, password is bcrypt hash (default: False)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config):
|
||||||
|
super().__init__(config)
|
||||||
|
self.logger = logging.getLogger("tisbackup.auth")
|
||||||
|
self.username = config.get("username")
|
||||||
|
self.password = config.get("password")
|
||||||
|
self.realm = config.get("realm", "TISBackup")
|
||||||
|
self.use_bcrypt = config.get("use_bcrypt", False)
|
||||||
|
|
||||||
|
if not self.username or not self.password:
|
||||||
|
raise ValueError("BasicAuth requires 'username' and 'password' in config")
|
||||||
|
|
||||||
|
if self.use_bcrypt:
|
||||||
|
try:
|
||||||
|
import bcrypt
|
||||||
|
self.bcrypt = bcrypt
|
||||||
|
except ImportError:
|
||||||
|
raise ImportError("bcrypt library required for password hashing. Install with: pip install bcrypt")
|
||||||
|
|
||||||
|
def is_authenticated(self):
|
||||||
|
"""Check if request has valid Basic Auth credentials."""
|
||||||
|
auth = request.authorization
|
||||||
|
|
||||||
|
if not auth:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if auth.username != self.username:
|
||||||
|
self.logger.warning(f"Failed authentication attempt for user: {auth.username}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.use_bcrypt:
|
||||||
|
# Compare bcrypt hash
|
||||||
|
password_bytes = auth.password.encode('utf-8')
|
||||||
|
hash_bytes = self.password.encode('utf-8') if isinstance(self.password, str) else self.password
|
||||||
|
return self.bcrypt.checkpw(password_bytes, hash_bytes)
|
||||||
|
else:
|
||||||
|
# Plain text comparison (not recommended for production)
|
||||||
|
return auth.password == self.password
|
||||||
|
|
||||||
|
def handle_unauthorized(self):
|
||||||
|
"""Return 401 with WWW-Authenticate header."""
|
||||||
|
from flask import Response
|
||||||
|
return Response(
|
||||||
|
'Authentication required',
|
||||||
|
401,
|
||||||
|
{'WWW-Authenticate': f'Basic realm="{self.realm}"'}
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_current_user(self):
|
||||||
|
"""Get current authenticated user."""
|
||||||
|
auth = request.authorization
|
||||||
|
if auth and self.is_authenticated():
|
||||||
|
return {"username": auth.username, "auth_type": "basic"}
|
||||||
|
return None
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Example integration of authentication providers with Flask app
|
||||||
|
|
||||||
|
This file shows how to integrate the authentication system into tisbackup_gui.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Flask, jsonify, render_template, request, redirect, url_for, session
|
||||||
|
from libtisbackup.auth import get_auth_provider
|
||||||
|
|
||||||
|
# Example configuration from tisbackup_gui.ini
|
||||||
|
auth_config = {
|
||||||
|
"type": "basic", # or "flask-login", "oauth", "none"
|
||||||
|
"basic": {
|
||||||
|
"username": "admin",
|
||||||
|
"password": "$2b$12$...", # bcrypt hash
|
||||||
|
"use_bcrypt": True,
|
||||||
|
"realm": "TISBackup Admin"
|
||||||
|
},
|
||||||
|
"flask-login": {
|
||||||
|
"users_file": "/etc/tis/users.txt", # username:bcrypt_hash per line
|
||||||
|
"use_bcrypt": True,
|
||||||
|
"login_view": "login"
|
||||||
|
},
|
||||||
|
"oauth": {
|
||||||
|
"provider": "google",
|
||||||
|
"client_id": "your-client-id",
|
||||||
|
"client_secret": "your-client-secret",
|
||||||
|
"redirect_uri": "http://localhost:8080/oauth/callback",
|
||||||
|
"authorized_domains": ["example.com"],
|
||||||
|
"authorized_users": ["admin@example.com"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_app_with_auth():
|
||||||
|
"""Example: Create Flask app with authentication."""
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.secret_key = "your-secret-key"
|
||||||
|
|
||||||
|
# Initialize authentication provider
|
||||||
|
auth_type = auth_config.get("type", "none")
|
||||||
|
provider_config = auth_config.get(auth_type, {})
|
||||||
|
auth = get_auth_provider(auth_type, provider_config)
|
||||||
|
auth.init_app(app)
|
||||||
|
|
||||||
|
# Protected routes
|
||||||
|
@app.route("/")
|
||||||
|
@auth.require_auth
|
||||||
|
def index():
|
||||||
|
user = auth.get_current_user()
|
||||||
|
return render_template("index.html", user=user)
|
||||||
|
|
||||||
|
@app.route("/api/backups")
|
||||||
|
@auth.require_auth
|
||||||
|
def api_backups():
|
||||||
|
return jsonify({"backups": []})
|
||||||
|
|
||||||
|
# Public routes (no auth required)
|
||||||
|
@app.route("/health")
|
||||||
|
def health():
|
||||||
|
return jsonify({"status": "ok"})
|
||||||
|
|
||||||
|
# Flask-Login specific routes
|
||||||
|
if auth_type == "flask-login":
|
||||||
|
@app.route("/login", methods=["GET", "POST"])
|
||||||
|
def login():
|
||||||
|
if request.method == "POST":
|
||||||
|
username = request.form.get("username")
|
||||||
|
password = request.form.get("password")
|
||||||
|
|
||||||
|
if auth.verify_password(username, password):
|
||||||
|
auth.login_user(username)
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
else:
|
||||||
|
return render_template("login.html", error="Invalid credentials")
|
||||||
|
|
||||||
|
return render_template("login.html")
|
||||||
|
|
||||||
|
@app.route("/logout")
|
||||||
|
def logout():
|
||||||
|
auth.logout()
|
||||||
|
return redirect(url_for("login"))
|
||||||
|
|
||||||
|
# OAuth specific routes
|
||||||
|
if auth_type == "oauth":
|
||||||
|
@app.route("/oauth/login")
|
||||||
|
def oauth_login():
|
||||||
|
redirect_uri = url_for("oauth_callback", _external=True)
|
||||||
|
return auth.oauth_client.authorize_redirect(redirect_uri)
|
||||||
|
|
||||||
|
@app.route("/oauth/callback")
|
||||||
|
def oauth_callback():
|
||||||
|
try:
|
||||||
|
token = auth.oauth_client.authorize_access_token()
|
||||||
|
user_info = auth.oauth_client.get(auth.userinfo_endpoint).json()
|
||||||
|
|
||||||
|
# Check authorization
|
||||||
|
if not auth.is_user_authorized(user_info):
|
||||||
|
return "Unauthorized: Your email/domain is not authorized", 403
|
||||||
|
|
||||||
|
# Store user in session
|
||||||
|
session["oauth_user"] = user_info
|
||||||
|
session["oauth_token"] = token
|
||||||
|
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
except Exception as e:
|
||||||
|
return f"OAuth callback error: {e}", 500
|
||||||
|
|
||||||
|
@app.route("/logout")
|
||||||
|
def logout():
|
||||||
|
auth.logout()
|
||||||
|
return redirect(url_for("oauth_login"))
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = create_app_with_auth()
|
||||||
|
app.run(debug=True, port=8080)
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Flask-Login authentication provider
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from flask import redirect, request, session, url_for
|
||||||
|
|
||||||
|
from .base import AuthProvider
|
||||||
|
|
||||||
|
|
||||||
|
class FlaskLoginProvider(AuthProvider):
|
||||||
|
"""Flask-Login based authentication provider.
|
||||||
|
|
||||||
|
Configuration:
|
||||||
|
users: Dict of {username: password_hash} or path to user file
|
||||||
|
secret_key: Flask secret key for sessions
|
||||||
|
login_view: Route name for login page (default: 'login')
|
||||||
|
use_bcrypt: If True, passwords are bcrypt hashes (default: True)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config):
|
||||||
|
super().__init__(config)
|
||||||
|
self.logger = logging.getLogger("tisbackup.auth")
|
||||||
|
self.login_manager = None
|
||||||
|
self.users = config.get("users", {})
|
||||||
|
self.login_view = config.get("login_view", "login")
|
||||||
|
self.use_bcrypt = config.get("use_bcrypt", True)
|
||||||
|
|
||||||
|
if self.use_bcrypt:
|
||||||
|
try:
|
||||||
|
import bcrypt
|
||||||
|
self.bcrypt = bcrypt
|
||||||
|
except ImportError:
|
||||||
|
raise ImportError("bcrypt library required. Install with: pip install bcrypt")
|
||||||
|
|
||||||
|
# Load users from file if path provided
|
||||||
|
users_file = config.get("users_file")
|
||||||
|
if users_file:
|
||||||
|
self._load_users_from_file(users_file)
|
||||||
|
|
||||||
|
def _load_users_from_file(self, filepath):
|
||||||
|
"""Load users from file (username:password_hash per line)."""
|
||||||
|
try:
|
||||||
|
with open(filepath) as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if line and not line.startswith('#'):
|
||||||
|
username, password_hash = line.split(':', 1)
|
||||||
|
self.users[username.strip()] = password_hash.strip()
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Failed to load users from {filepath}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def init_app(self, app):
|
||||||
|
"""Initialize Flask-Login with the app."""
|
||||||
|
try:
|
||||||
|
from flask_login import LoginManager, UserMixin
|
||||||
|
except ImportError:
|
||||||
|
raise ImportError("flask-login library required. Install with: pip install flask-login")
|
||||||
|
|
||||||
|
self.login_manager = LoginManager()
|
||||||
|
self.login_manager.init_app(app)
|
||||||
|
self.login_manager.login_view = self.login_view
|
||||||
|
|
||||||
|
# Simple User class
|
||||||
|
class User(UserMixin):
|
||||||
|
def __init__(self, username):
|
||||||
|
self.id = username
|
||||||
|
self.username = username
|
||||||
|
|
||||||
|
self.User = User
|
||||||
|
|
||||||
|
@self.login_manager.user_loader
|
||||||
|
def load_user(user_id):
|
||||||
|
if user_id in self.users:
|
||||||
|
return User(user_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def verify_password(self, username, password):
|
||||||
|
"""Verify username and password.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
username: Username to check
|
||||||
|
password: Password to verify
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if valid, False otherwise
|
||||||
|
"""
|
||||||
|
if username not in self.users:
|
||||||
|
return False
|
||||||
|
|
||||||
|
stored_hash = self.users[username]
|
||||||
|
|
||||||
|
if self.use_bcrypt:
|
||||||
|
password_bytes = password.encode('utf-8')
|
||||||
|
hash_bytes = stored_hash.encode('utf-8') if isinstance(stored_hash, str) else stored_hash
|
||||||
|
return self.bcrypt.checkpw(password_bytes, hash_bytes)
|
||||||
|
else:
|
||||||
|
return password == stored_hash
|
||||||
|
|
||||||
|
def login_user(self, username):
|
||||||
|
"""Login a user by username.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
username: Username to login
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if successful
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from flask_login import login_user
|
||||||
|
except ImportError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if username in self.users:
|
||||||
|
user = self.User(username)
|
||||||
|
login_user(user)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def is_authenticated(self):
|
||||||
|
"""Check if current user is authenticated."""
|
||||||
|
try:
|
||||||
|
from flask_login import current_user
|
||||||
|
return current_user.is_authenticated
|
||||||
|
except ImportError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def handle_unauthorized(self):
|
||||||
|
"""Redirect to login page."""
|
||||||
|
return redirect(url_for(self.login_view))
|
||||||
|
|
||||||
|
def get_current_user(self):
|
||||||
|
"""Get current authenticated user."""
|
||||||
|
try:
|
||||||
|
from flask_login import current_user
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return {
|
||||||
|
"username": current_user.username,
|
||||||
|
"auth_type": "flask-login"
|
||||||
|
}
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def logout(self):
|
||||||
|
"""Logout current user."""
|
||||||
|
try:
|
||||||
|
from flask_login import logout_user
|
||||||
|
logout_user()
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
OAuth2 authentication provider
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from flask import redirect, request, session, url_for
|
||||||
|
|
||||||
|
from .base import AuthProvider
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthProvider(AuthProvider):
|
||||||
|
"""OAuth2 authentication provider.
|
||||||
|
|
||||||
|
Supports multiple OAuth providers (Google, GitHub, GitLab, etc.)
|
||||||
|
|
||||||
|
Configuration:
|
||||||
|
provider: OAuth provider name ('google', 'github', 'gitlab', 'generic')
|
||||||
|
client_id: OAuth client ID
|
||||||
|
client_secret: OAuth client secret
|
||||||
|
redirect_uri: OAuth redirect URI
|
||||||
|
scopes: List of OAuth scopes (default: ['openid', 'email', 'profile'])
|
||||||
|
authorized_domains: List of allowed email domains (optional)
|
||||||
|
authorized_users: List of allowed email addresses (optional)
|
||||||
|
login_view: Route name for login page (default: 'oauth_login')
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config):
|
||||||
|
super().__init__(config)
|
||||||
|
self.logger = logging.getLogger("tisbackup.auth")
|
||||||
|
|
||||||
|
self.provider_name = config.get("provider", "generic").lower()
|
||||||
|
self.client_id = config.get("client_id")
|
||||||
|
self.client_secret = config.get("client_secret")
|
||||||
|
self.redirect_uri = config.get("redirect_uri")
|
||||||
|
self.scopes = config.get("scopes", ["openid", "email", "profile"])
|
||||||
|
self.authorized_domains = config.get("authorized_domains", [])
|
||||||
|
self.authorized_users = config.get("authorized_users", [])
|
||||||
|
self.login_view = config.get("login_view", "oauth_login")
|
||||||
|
|
||||||
|
if not self.client_id or not self.client_secret:
|
||||||
|
raise ValueError("OAuth requires 'client_id' and 'client_secret' in config")
|
||||||
|
|
||||||
|
# Provider-specific configurations
|
||||||
|
self.provider_configs = {
|
||||||
|
"google": {
|
||||||
|
"authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||||
|
"token_endpoint": "https://oauth2.googleapis.com/token",
|
||||||
|
"userinfo_endpoint": "https://www.googleapis.com/oauth2/v2/userinfo",
|
||||||
|
"default_scopes": ["openid", "email", "profile"],
|
||||||
|
},
|
||||||
|
"github": {
|
||||||
|
"authorization_endpoint": "https://github.com/login/oauth/authorize",
|
||||||
|
"token_endpoint": "https://github.com/login/oauth/access_token",
|
||||||
|
"userinfo_endpoint": "https://api.github.com/user",
|
||||||
|
"default_scopes": ["read:user", "user:email"],
|
||||||
|
},
|
||||||
|
"gitlab": {
|
||||||
|
"authorization_endpoint": "https://gitlab.com/oauth/authorize",
|
||||||
|
"token_endpoint": "https://gitlab.com/oauth/token",
|
||||||
|
"userinfo_endpoint": "https://gitlab.com/api/v4/user",
|
||||||
|
"default_scopes": ["read_user", "email"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get provider config or use generic
|
||||||
|
self.provider_config = self.provider_configs.get(self.provider_name, {})
|
||||||
|
|
||||||
|
# Override with custom endpoints if provided
|
||||||
|
self.authorization_endpoint = config.get(
|
||||||
|
"authorization_endpoint",
|
||||||
|
self.provider_config.get("authorization_endpoint")
|
||||||
|
)
|
||||||
|
self.token_endpoint = config.get(
|
||||||
|
"token_endpoint",
|
||||||
|
self.provider_config.get("token_endpoint")
|
||||||
|
)
|
||||||
|
self.userinfo_endpoint = config.get(
|
||||||
|
"userinfo_endpoint",
|
||||||
|
self.provider_config.get("userinfo_endpoint")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use provider default scopes if not specified
|
||||||
|
if not config.get("scopes"):
|
||||||
|
self.scopes = self.provider_config.get("default_scopes", self.scopes)
|
||||||
|
|
||||||
|
def init_app(self, app):
|
||||||
|
"""Initialize OAuth with the app."""
|
||||||
|
try:
|
||||||
|
from authlib.integrations.flask_client import OAuth
|
||||||
|
except ImportError:
|
||||||
|
raise ImportError(
|
||||||
|
"authlib library required for OAuth. "
|
||||||
|
"Install with: pip install authlib requests"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.oauth = OAuth(app)
|
||||||
|
|
||||||
|
# Register OAuth client
|
||||||
|
self.oauth_client = self.oauth.register(
|
||||||
|
name=self.provider_name,
|
||||||
|
client_id=self.client_id,
|
||||||
|
client_secret=self.client_secret,
|
||||||
|
server_metadata_url=None,
|
||||||
|
authorize_url=self.authorization_endpoint,
|
||||||
|
access_token_url=self.token_endpoint,
|
||||||
|
userinfo_endpoint=self.userinfo_endpoint,
|
||||||
|
client_kwargs={"scope": " ".join(self.scopes)},
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_user_authorized(self, user_info):
|
||||||
|
"""Check if user is authorized based on email/domain.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_info: Dict with user information (must contain 'email')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if authorized
|
||||||
|
"""
|
||||||
|
email = user_info.get("email", "").lower()
|
||||||
|
|
||||||
|
# Check specific users
|
||||||
|
if self.authorized_users:
|
||||||
|
if email in [u.lower() for u in self.authorized_users]:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check domains
|
||||||
|
if self.authorized_domains:
|
||||||
|
domain = email.split("@")[-1] if "@" in email else ""
|
||||||
|
if domain in [d.lower() for d in self.authorized_domains]:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# If no restrictions configured, allow all
|
||||||
|
if not self.authorized_users and not self.authorized_domains:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def is_authenticated(self):
|
||||||
|
"""Check if current user is authenticated via OAuth."""
|
||||||
|
return "oauth_user" in session
|
||||||
|
|
||||||
|
def handle_unauthorized(self):
|
||||||
|
"""Redirect to OAuth login."""
|
||||||
|
return redirect(url_for(self.login_view))
|
||||||
|
|
||||||
|
def get_current_user(self):
|
||||||
|
"""Get current authenticated user."""
|
||||||
|
user_info = session.get("oauth_user")
|
||||||
|
if user_info:
|
||||||
|
return {
|
||||||
|
**user_info,
|
||||||
|
"auth_type": "oauth",
|
||||||
|
"provider": self.provider_name
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
def logout(self):
|
||||||
|
"""Logout current user."""
|
||||||
|
session.pop("oauth_user", None)
|
||||||
|
session.pop("oauth_token", None)
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
#!/usr/bin/python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# This file is part of TISBackup
|
|
||||||
#
|
|
||||||
# TISBackup is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# TISBackup is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with TISBackup. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
#
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import sys
|
|
||||||
try:
|
|
||||||
sys.stderr = open('/dev/null') # Silence silly warnings from paramiko
|
|
||||||
import paramiko
|
|
||||||
except ImportError as e:
|
|
||||||
print(("Error : can not load paramiko library %s" % e))
|
|
||||||
raise
|
|
||||||
|
|
||||||
sys.stderr = sys.__stderr__
|
|
||||||
|
|
||||||
from libtisbackup.common import *
|
|
||||||
|
|
||||||
class backup_mysql(backup_generic):
|
|
||||||
"""Backup a mysql database as gzipped sql file through ssh"""
|
|
||||||
type = 'mysql+ssh'
|
|
||||||
required_params = backup_generic.required_params + ['db_user','db_passwd','private_key']
|
|
||||||
optional_params = backup_generic.optional_params + ['db_name']
|
|
||||||
|
|
||||||
db_name=''
|
|
||||||
db_user=''
|
|
||||||
db_passwd=''
|
|
||||||
|
|
||||||
dest_dir = ""
|
|
||||||
|
|
||||||
def do_backup(self,stats):
|
|
||||||
self.dest_dir = os.path.join(self.backup_dir,self.backup_start_date)
|
|
||||||
|
|
||||||
|
|
||||||
if not os.path.isdir(self.dest_dir):
|
|
||||||
if not self.dry_run:
|
|
||||||
os.makedirs(self.dest_dir)
|
|
||||||
else:
|
|
||||||
print(('mkdir "%s"' % self.dest_dir))
|
|
||||||
else:
|
|
||||||
raise Exception('backup destination directory already exists : %s' % self.dest_dir)
|
|
||||||
|
|
||||||
self.logger.debug('[%s] Connecting to %s with user root and key %s',self.backup_name,self.server_name,self.private_key)
|
|
||||||
try:
|
|
||||||
mykey = paramiko.RSAKey.from_private_key_file(self.private_key)
|
|
||||||
except paramiko.SSHException:
|
|
||||||
#mykey = paramiko.DSSKey.from_private_key_file(self.private_key)
|
|
||||||
mykey = paramiko.Ed25519Key.from_private_key_file(self.private_key)
|
|
||||||
|
|
||||||
self.ssh = paramiko.SSHClient()
|
|
||||||
self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
||||||
self.ssh.connect(self.server_name,username='root',pkey = mykey, port=self.ssh_port)
|
|
||||||
|
|
||||||
self.db_passwd=self.db_passwd.replace('$','\$')
|
|
||||||
if not self.db_name:
|
|
||||||
stats['log']= "Successfully backuping processed to the following databases :"
|
|
||||||
stats['status']='List'
|
|
||||||
cmd = 'mysql -N -B -p -e "SHOW DATABASES;" -u ' + self.db_user +' -p' + self.db_passwd + ' 2> /dev/null'
|
|
||||||
self.logger.debug('[%s] List databases: %s',self.backup_name,cmd)
|
|
||||||
(error_code,output) = ssh_exec(cmd,ssh=self.ssh)
|
|
||||||
self.logger.debug("[%s] Output of %s :\n%s",self.backup_name,cmd,output)
|
|
||||||
if error_code:
|
|
||||||
raise Exception('Aborting, Not null exit code (%i) for "%s"' % (error_code,cmd))
|
|
||||||
databases = output.split('\n')
|
|
||||||
for database in databases:
|
|
||||||
if database != "":
|
|
||||||
self.db_name = database.rstrip()
|
|
||||||
self.do_mysqldump(stats)
|
|
||||||
|
|
||||||
else:
|
|
||||||
stats['log']= "Successfully backup processed to the following database :"
|
|
||||||
self.do_mysqldump(stats)
|
|
||||||
|
|
||||||
|
|
||||||
def do_mysqldump(self,stats):
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
t = datetime.datetime.now()
|
|
||||||
backup_start_date = t.strftime('%Y%m%d-%Hh%Mm%S')
|
|
||||||
|
|
||||||
# dump db
|
|
||||||
stats['status']='Dumping'
|
|
||||||
cmd = 'mysqldump --single-transaction -u' + self.db_user +' -p' + self.db_passwd + ' ' + self.db_name + ' > /tmp/' + self.db_name + '-' + backup_start_date + '.sql'
|
|
||||||
self.logger.debug('[%s] Dump DB : %s',self.backup_name,cmd)
|
|
||||||
if not self.dry_run:
|
|
||||||
(error_code,output) = ssh_exec(cmd,ssh=self.ssh)
|
|
||||||
print(output)
|
|
||||||
self.logger.debug("[%s] Output of %s :\n%s",self.backup_name,cmd,output)
|
|
||||||
if error_code:
|
|
||||||
raise Exception('Aborting, Not null exit code (%i) for "%s"' % (error_code,cmd))
|
|
||||||
|
|
||||||
# zip the file
|
|
||||||
stats['status']='Zipping'
|
|
||||||
cmd = 'gzip /tmp/' + self.db_name + '-' + backup_start_date + '.sql'
|
|
||||||
self.logger.debug('[%s] Compress backup : %s',self.backup_name,cmd)
|
|
||||||
if not self.dry_run:
|
|
||||||
(error_code,output) = ssh_exec(cmd,ssh=self.ssh)
|
|
||||||
self.logger.debug("[%s] Output of %s :\n%s",self.backup_name,cmd,output)
|
|
||||||
if error_code:
|
|
||||||
raise Exception('Aborting, Not null exit code (%i) for "%s"' % (error_code,cmd))
|
|
||||||
|
|
||||||
# get the file
|
|
||||||
stats['status']='SFTP'
|
|
||||||
filepath = '/tmp/' + self.db_name + '-' + backup_start_date + '.sql.gz'
|
|
||||||
localpath = os.path.join(self.dest_dir , self.db_name + '.sql.gz')
|
|
||||||
self.logger.debug('[%s] Get gz backup with sftp on %s from %s to %s',self.backup_name,self.server_name,filepath,localpath)
|
|
||||||
if not self.dry_run:
|
|
||||||
transport = self.ssh.get_transport()
|
|
||||||
sftp = paramiko.SFTPClient.from_transport(transport)
|
|
||||||
sftp.get(filepath, localpath)
|
|
||||||
sftp.close()
|
|
||||||
|
|
||||||
if not self.dry_run:
|
|
||||||
stats['total_files_count']=1 + stats.get('total_files_count', 0)
|
|
||||||
stats['written_files_count']=1 + stats.get('written_files_count', 0)
|
|
||||||
stats['total_bytes']=os.stat(localpath).st_size + stats.get('total_bytes', 0)
|
|
||||||
stats['written_bytes']=os.stat(localpath).st_size + stats.get('written_bytes', 0)
|
|
||||||
stats['log'] = '%s "%s"' % (stats['log'] ,self.db_name)
|
|
||||||
stats['backup_location'] = self.dest_dir
|
|
||||||
|
|
||||||
stats['status']='RMTemp'
|
|
||||||
cmd = 'rm -f /tmp/' + self.db_name + '-' + backup_start_date + '.sql.gz'
|
|
||||||
self.logger.debug('[%s] Remove temp gzip : %s',self.backup_name,cmd)
|
|
||||||
if not self.dry_run:
|
|
||||||
(error_code,output) = ssh_exec(cmd,ssh=self.ssh)
|
|
||||||
self.logger.debug("[%s] Output of %s :\n%s",self.backup_name,cmd,output)
|
|
||||||
if error_code:
|
|
||||||
raise Exception('Aborting, Not null exit code (%i) for "%s"' % (error_code,cmd))
|
|
||||||
stats['status']='OK'
|
|
||||||
|
|
||||||
def register_existingbackups(self):
|
|
||||||
"""scan backup dir and insert stats in database"""
|
|
||||||
|
|
||||||
registered = [b['backup_location'] for b in self.dbstat.query('select distinct backup_location from stats where backup_name=?',(self.backup_name,))]
|
|
||||||
|
|
||||||
filelist = os.listdir(self.backup_dir)
|
|
||||||
filelist.sort()
|
|
||||||
p = re.compile('^\d{8,8}-\d{2,2}h\d{2,2}m\d{2,2}$')
|
|
||||||
for item in filelist:
|
|
||||||
if p.match(item):
|
|
||||||
dir_name = os.path.join(self.backup_dir,item)
|
|
||||||
if not dir_name in registered:
|
|
||||||
start = datetime.datetime.strptime(item,'%Y%m%d-%Hh%Mm%S').isoformat()
|
|
||||||
if fileisodate(dir_name)>start:
|
|
||||||
stop = fileisodate(dir_name)
|
|
||||||
else:
|
|
||||||
stop = start
|
|
||||||
self.logger.info('Registering %s started on %s',dir_name,start)
|
|
||||||
self.logger.debug(' Disk usage %s','du -sb "%s"' % dir_name)
|
|
||||||
if not self.dry_run:
|
|
||||||
size_bytes = int(os.popen('du -sb "%s"' % dir_name).read().split('\t')[0])
|
|
||||||
else:
|
|
||||||
size_bytes = 0
|
|
||||||
self.logger.debug(' Size in bytes : %i',size_bytes)
|
|
||||||
if not self.dry_run:
|
|
||||||
self.dbstat.add(self.backup_name,self.server_name,'',\
|
|
||||||
backup_start=start,backup_end = stop,status='OK',total_bytes=size_bytes,backup_location=dir_name)
|
|
||||||
else:
|
|
||||||
self.logger.info('Skipping %s, already registered',dir_name)
|
|
||||||
|
|
||||||
|
|
||||||
register_driver(backup_mysql)
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
#!/usr/bin/python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# This file is part of TISBackup
|
|
||||||
#
|
|
||||||
# TISBackup is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# TISBackup is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with TISBackup. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
#
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
import sys
|
|
||||||
try:
|
|
||||||
sys.stderr = open('/dev/null') # Silence silly warnings from paramiko
|
|
||||||
import paramiko
|
|
||||||
except ImportError as e:
|
|
||||||
print(("Error : can not load paramiko library %s" % e))
|
|
||||||
raise
|
|
||||||
|
|
||||||
sys.stderr = sys.__stderr__
|
|
||||||
|
|
||||||
import datetime
|
|
||||||
import base64
|
|
||||||
import os
|
|
||||||
from libtisbackup.common import *
|
|
||||||
import re
|
|
||||||
|
|
||||||
class backup_oracle(backup_generic):
|
|
||||||
"""Backup a oracle database as zipped file through ssh"""
|
|
||||||
type = 'oracle+ssh'
|
|
||||||
required_params = backup_generic.required_params + ['db_name','private_key', 'userid']
|
|
||||||
optional_params = ['username', 'remote_backup_dir', 'ignore_error_oracle_code']
|
|
||||||
db_name=''
|
|
||||||
username='oracle'
|
|
||||||
remote_backup_dir = r'/home/oracle/backup'
|
|
||||||
ignore_error_oracle_code = [ ]
|
|
||||||
|
|
||||||
def do_backup(self,stats):
|
|
||||||
|
|
||||||
self.logger.debug('[%s] Connecting to %s with user %s and key %s',self.backup_name,self.server_name,self.username,self.private_key)
|
|
||||||
try:
|
|
||||||
mykey = paramiko.RSAKey.from_private_key_file(self.private_key)
|
|
||||||
except paramiko.SSHException:
|
|
||||||
#mykey = paramiko.DSSKey.from_private_key_file(self.private_key)
|
|
||||||
mykey = paramiko.Ed25519Key.from_private_key_file(self.private_key)
|
|
||||||
|
|
||||||
self.ssh = paramiko.SSHClient()
|
|
||||||
self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
||||||
self.ssh.connect(self.server_name,username=self.username,pkey = mykey,port=self.ssh_port)
|
|
||||||
|
|
||||||
t = datetime.datetime.now()
|
|
||||||
self.backup_start_date = t.strftime('%Y%m%d-%Hh%Mm%S')
|
|
||||||
dumpfile= self.remote_backup_dir + '/' + self.db_name + '_' + self.backup_start_date+'.dmp'
|
|
||||||
dumplog = self.remote_backup_dir + '/' + self.db_name + '_' + self.backup_start_date+'.log'
|
|
||||||
|
|
||||||
self.dest_dir = os.path.join(self.backup_dir,self.backup_start_date)
|
|
||||||
if not os.path.isdir(self.dest_dir):
|
|
||||||
if not self.dry_run:
|
|
||||||
os.makedirs(self.dest_dir)
|
|
||||||
else:
|
|
||||||
print(('mkdir "%s"' % self.dest_dir))
|
|
||||||
else:
|
|
||||||
raise Exception('backup destination directory already exists : %s' % self.dest_dir)
|
|
||||||
# dump db
|
|
||||||
stats['status']='Dumping'
|
|
||||||
cmd = "exp '%s' file='%s' grants=y log='%s'"% (self.userid,dumpfile, dumplog)
|
|
||||||
self.logger.debug('[%s] Dump DB : %s',self.backup_name,cmd)
|
|
||||||
if not self.dry_run:
|
|
||||||
(error_code,output) = ssh_exec(cmd,ssh=self.ssh)
|
|
||||||
self.logger.debug("[%s] Output of %s :\n%s",self.backup_name,cmd,output)
|
|
||||||
if error_code:
|
|
||||||
localpath = os.path.join(self.dest_dir , self.db_name + '.log')
|
|
||||||
self.logger.debug('[%s] Get log file with sftp on %s from %s to %s',self.backup_name,self.server_name,dumplog,localpath)
|
|
||||||
transport = self.ssh.get_transport()
|
|
||||||
sftp = paramiko.SFTPClient.from_transport(transport)
|
|
||||||
sftp.get(dumplog, localpath)
|
|
||||||
sftp.close()
|
|
||||||
|
|
||||||
file = open(localpath)
|
|
||||||
for line in file:
|
|
||||||
if re.search('EXP-[0-9]+:', line) and not re.match('EXP-[0-9]+:', line).group(0).replace(':','') in self.ignore_error_oracle_code:
|
|
||||||
stats['status']='RMTemp'
|
|
||||||
self.clean_dumpfiles(dumpfile,dumplog)
|
|
||||||
raise Exception('Aborting, Not null exit code (%s) for "%s"' % (re.match('EXP-[0-9]+:', line).group(0).replace(':',''),cmd))
|
|
||||||
file.close()
|
|
||||||
|
|
||||||
# zip the file
|
|
||||||
stats['status']='Zipping'
|
|
||||||
cmd = 'gzip %s' % dumpfile
|
|
||||||
self.logger.debug('[%s] Compress backup : %s',self.backup_name,cmd)
|
|
||||||
if not self.dry_run:
|
|
||||||
(error_code,output) = ssh_exec(cmd,ssh=self.ssh)
|
|
||||||
self.logger.debug("[%s] Output of %s :\n%s",self.backup_name,cmd,output)
|
|
||||||
if error_code:
|
|
||||||
raise Exception('Aborting, Not null exit code (%i) for "%s"' % (error_code,cmd))
|
|
||||||
|
|
||||||
# get the file
|
|
||||||
stats['status']='SFTP'
|
|
||||||
filepath = dumpfile + '.gz'
|
|
||||||
localpath = os.path.join(self.dest_dir , self.db_name + '.dmp.gz')
|
|
||||||
self.logger.debug('[%s] Get gz backup with sftp on %s from %s to %s',self.backup_name,self.server_name,filepath,localpath)
|
|
||||||
if not self.dry_run:
|
|
||||||
transport = self.ssh.get_transport()
|
|
||||||
sftp = paramiko.SFTPClient.from_transport(transport)
|
|
||||||
sftp.get(filepath, localpath)
|
|
||||||
sftp.close()
|
|
||||||
|
|
||||||
if not self.dry_run:
|
|
||||||
stats['total_files_count']=1
|
|
||||||
stats['written_files_count']=1
|
|
||||||
stats['total_bytes']=os.stat(localpath).st_size
|
|
||||||
stats['written_bytes']=os.stat(localpath).st_size
|
|
||||||
stats['log']='gzip dump of DB %s:%s (%d bytes) to %s' % (self.server_name,self.db_name, stats['written_bytes'], localpath)
|
|
||||||
stats['backup_location'] = self.dest_dir
|
|
||||||
stats['status']='RMTemp'
|
|
||||||
self.clean_dumpfiles(dumpfile,dumplog)
|
|
||||||
stats['status']='OK'
|
|
||||||
|
|
||||||
def register_existingbackups(self):
|
|
||||||
"""scan backup dir and insert stats in database"""
|
|
||||||
|
|
||||||
registered = [b['backup_location'] for b in self.dbstat.query('select distinct backup_location from stats where backup_name=?',(self.backup_name,))]
|
|
||||||
|
|
||||||
filelist = os.listdir(self.backup_dir)
|
|
||||||
filelist.sort()
|
|
||||||
p = re.compile('^\d{8,8}-\d{2,2}h\d{2,2}m\d{2,2}$')
|
|
||||||
for item in filelist:
|
|
||||||
if p.match(item):
|
|
||||||
dir_name = os.path.join(self.backup_dir,item)
|
|
||||||
if not dir_name in registered:
|
|
||||||
start = datetime.datetime.strptime(item,'%Y%m%d-%Hh%Mm%S').isoformat()
|
|
||||||
if fileisodate(dir_name)>start:
|
|
||||||
stop = fileisodate(dir_name)
|
|
||||||
else:
|
|
||||||
stop = start
|
|
||||||
self.logger.info('Registering %s started on %s',dir_name,start)
|
|
||||||
self.logger.debug(' Disk usage %s','du -sb "%s"' % dir_name)
|
|
||||||
if not self.dry_run:
|
|
||||||
size_bytes = int(os.popen('du -sb "%s"' % dir_name).read().split('\t')[0])
|
|
||||||
else:
|
|
||||||
size_bytes = 0
|
|
||||||
self.logger.debug(' Size in bytes : %i',size_bytes)
|
|
||||||
if not self.dry_run:
|
|
||||||
self.dbstat.add(self.backup_name,self.server_name,'',\
|
|
||||||
backup_start=start,backup_end = stop,status='OK',total_bytes=size_bytes,backup_location=dir_name)
|
|
||||||
else:
|
|
||||||
self.logger.info('Skipping %s, already registered',dir_name)
|
|
||||||
|
|
||||||
|
|
||||||
def clean_dumpfiles(self,dumpfile,dumplog):
|
|
||||||
cmd = 'rm -f "%s.gz" "%s"' %( dumpfile , dumplog)
|
|
||||||
self.logger.debug('[%s] Remove temp gzip : %s',self.backup_name,cmd)
|
|
||||||
if not self.dry_run:
|
|
||||||
(error_code,output) = ssh_exec(cmd,ssh=self.ssh)
|
|
||||||
self.logger.debug("[%s] Output of %s :\n%s",self.backup_name,cmd,output)
|
|
||||||
if error_code:
|
|
||||||
raise Exception('Aborting, Not null exit code (%i) for "%s"' % (error_code,cmd))
|
|
||||||
cmd = 'rm -f '+self.remote_backup_dir + '/' + self.db_name + '_' + self.backup_start_date+'.dmp'
|
|
||||||
self.logger.debug('[%s] Remove temp dump : %s',self.backup_name,cmd)
|
|
||||||
if not self.dry_run:
|
|
||||||
(error_code,output) = ssh_exec(cmd,ssh=self.ssh)
|
|
||||||
self.logger.debug("[%s] Output of %s :\n%s",self.backup_name,cmd,output)
|
|
||||||
if error_code:
|
|
||||||
raise Exception('Aborting, Not null exit code (%i) for "%s"' % (error_code,cmd))
|
|
||||||
|
|
||||||
register_driver(backup_oracle)
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
#!/usr/bin/python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# This file is part of TISBackup
|
|
||||||
#
|
|
||||||
# TISBackup is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# TISBackup is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with TISBackup. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
#
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
import sys
|
|
||||||
try:
|
|
||||||
sys.stderr = open('/dev/null') # Silence silly warnings from paramiko
|
|
||||||
import paramiko
|
|
||||||
except ImportError as e:
|
|
||||||
print(("Error : can not load paramiko library %s" % e))
|
|
||||||
raise
|
|
||||||
|
|
||||||
sys.stderr = sys.__stderr__
|
|
||||||
|
|
||||||
from .common import *
|
|
||||||
|
|
||||||
class backup_pgsql(backup_generic):
|
|
||||||
"""Backup a postgresql database as gzipped sql file through ssh"""
|
|
||||||
type = 'pgsql+ssh'
|
|
||||||
required_params = backup_generic.required_params + ['private_key']
|
|
||||||
optional_params = backup_generic.optional_params + ['db_name','tmp_dir','encoding']
|
|
||||||
|
|
||||||
db_name = ''
|
|
||||||
tmp_dir = '/tmp'
|
|
||||||
encoding = 'UTF8'
|
|
||||||
|
|
||||||
def do_backup(self,stats):
|
|
||||||
self.dest_dir = os.path.join(self.backup_dir,self.backup_start_date)
|
|
||||||
|
|
||||||
if not os.path.isdir(self.dest_dir):
|
|
||||||
if not self.dry_run:
|
|
||||||
os.makedirs(self.dest_dir)
|
|
||||||
else:
|
|
||||||
print(('mkdir "%s"' % self.dest_dir))
|
|
||||||
else:
|
|
||||||
raise Exception('backup destination directory already exists : %s' % self.dest_dir)
|
|
||||||
|
|
||||||
try:
|
|
||||||
mykey = paramiko.RSAKey.from_private_key_file(self.private_key)
|
|
||||||
except paramiko.SSHException:
|
|
||||||
#mykey = paramiko.DSSKey.from_private_key_file(self.private_key)
|
|
||||||
mykey = paramiko.Ed25519Key.from_private_key_file(self.private_key)
|
|
||||||
|
|
||||||
self.logger.debug('[%s] Trying to connect to "%s" with username root and key "%s"',self.backup_name,self.server_name,self.private_key)
|
|
||||||
self.ssh = paramiko.SSHClient()
|
|
||||||
self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
||||||
self.ssh.connect(self.server_name,username='root',pkey = mykey,port=self.ssh_port)
|
|
||||||
|
|
||||||
|
|
||||||
if self.db_name:
|
|
||||||
stats['log']= "Successfully backup processed to the following database :"
|
|
||||||
self.do_pgsqldump(stats)
|
|
||||||
else:
|
|
||||||
stats['log']= "Successfully backuping processed to the following databases :"
|
|
||||||
stats['status']='List'
|
|
||||||
cmd = """su - postgres -c 'psql -A -t -c "SELECT datname FROM pg_database WHERE datistemplate = false;"' 2> /dev/null"""
|
|
||||||
self.logger.debug('[%s] List databases: %s',self.backup_name,cmd)
|
|
||||||
(error_code,output) = ssh_exec(cmd,ssh=self.ssh)
|
|
||||||
self.logger.debug("[%s] Output of %s :\n%s",self.backup_name,cmd,output)
|
|
||||||
if error_code:
|
|
||||||
raise Exception('Aborting, Not null exit code (%i) for "%s"' % (error_code,cmd))
|
|
||||||
databases = output.split('\n')
|
|
||||||
for database in databases:
|
|
||||||
if database.strip() not in ("", "template0", "template1"):
|
|
||||||
self.db_name = database.strip()
|
|
||||||
self.do_pgsqldump(stats)
|
|
||||||
|
|
||||||
|
|
||||||
stats['status']='OK'
|
|
||||||
|
|
||||||
|
|
||||||
def do_pgsqldump(self,stats):
|
|
||||||
t = datetime.datetime.now()
|
|
||||||
backup_start_date = t.strftime('%Y%m%d-%Hh%Mm%S')
|
|
||||||
params = {
|
|
||||||
'encoding':self.encoding,
|
|
||||||
'db_name':self.db_name,
|
|
||||||
'tmp_dir':self.tmp_dir,
|
|
||||||
'dest_dir':self.dest_dir,
|
|
||||||
'backup_start_date':backup_start_date}
|
|
||||||
# dump db
|
|
||||||
filepath = '%(tmp_dir)s/%(db_name)s-%(backup_start_date)s.sql.gz' % params
|
|
||||||
cmd = "su - postgres -c 'pg_dump -E %(encoding)s -Z9 %(db_name)s'" % params
|
|
||||||
cmd += ' > ' + filepath
|
|
||||||
self.logger.debug('[%s] %s ',self.backup_name,cmd)
|
|
||||||
if not self.dry_run:
|
|
||||||
(error_code,output) = ssh_exec(cmd,ssh=self.ssh)
|
|
||||||
self.logger.debug("[%s] Output of %s :\n%s",self.backup_name,cmd,output)
|
|
||||||
if error_code:
|
|
||||||
raise Exception('Aborting, Not null exit code (%i) for "%s"' % (error_code,cmd))
|
|
||||||
|
|
||||||
# get the file
|
|
||||||
localpath = '%(dest_dir)s/%(db_name)s-%(backup_start_date)s.sql.gz' % params
|
|
||||||
self.logger.debug('[%s] get the file using sftp from "%s" to "%s" ',self.backup_name,filepath,localpath)
|
|
||||||
if not self.dry_run:
|
|
||||||
transport = self.ssh.get_transport()
|
|
||||||
sftp = paramiko.SFTPClient.from_transport(transport)
|
|
||||||
sftp.get(filepath, localpath)
|
|
||||||
sftp.close()
|
|
||||||
|
|
||||||
if not self.dry_run:
|
|
||||||
stats['total_files_count']=1 + stats.get('total_files_count', 0)
|
|
||||||
stats['written_files_count']=1 + stats.get('written_files_count', 0)
|
|
||||||
stats['total_bytes']=os.stat(localpath).st_size + stats.get('total_bytes', 0)
|
|
||||||
stats['written_bytes']=os.stat(localpath).st_size + stats.get('written_bytes', 0)
|
|
||||||
stats['log'] = '%s "%s"' % (stats['log'] ,self.db_name)
|
|
||||||
stats['backup_location'] = self.dest_dir
|
|
||||||
|
|
||||||
cmd = 'rm -f %(tmp_dir)s/%(db_name)s-%(backup_start_date)s.sql.gz' % params
|
|
||||||
self.logger.debug('[%s] %s ',self.backup_name,cmd)
|
|
||||||
if not self.dry_run:
|
|
||||||
(error_code,output) = ssh_exec(cmd,ssh=self.ssh)
|
|
||||||
self.logger.debug("[%s] Output of %s :\n%s",self.backup_name,cmd,output)
|
|
||||||
if error_code:
|
|
||||||
raise Exception('Aborting, Not null exit code (%i) for "%s"' % (error_code,cmd))
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def register_existingbackups(self):
|
|
||||||
"""scan backup dir and insert stats in database"""
|
|
||||||
|
|
||||||
registered = [b['backup_location'] for b in self.dbstat.query('select distinct backup_location from stats where backup_name=?',(self.backup_name,))]
|
|
||||||
|
|
||||||
filelist = os.listdir(self.backup_dir)
|
|
||||||
filelist.sort()
|
|
||||||
p = re.compile('^\d{8,8}-\d{2,2}h\d{2,2}m\d{2,2}$')
|
|
||||||
for item in filelist:
|
|
||||||
if p.match(item):
|
|
||||||
dir_name = os.path.join(self.backup_dir,item)
|
|
||||||
if not dir_name in registered:
|
|
||||||
start = datetime.datetime.strptime(item,'%Y%m%d-%Hh%Mm%S').isoformat()
|
|
||||||
if fileisodate(dir_name)>start:
|
|
||||||
stop = fileisodate(dir_name)
|
|
||||||
else:
|
|
||||||
stop = start
|
|
||||||
self.logger.info('Registering %s started on %s',dir_name,start)
|
|
||||||
self.logger.debug(' Disk usage %s','du -sb "%s"' % dir_name)
|
|
||||||
if not self.dry_run:
|
|
||||||
size_bytes = int(os.popen('du -sb "%s"' % dir_name).read().split('\t')[0])
|
|
||||||
else:
|
|
||||||
size_bytes = 0
|
|
||||||
self.logger.debug(' Size in bytes : %i',size_bytes)
|
|
||||||
if not self.dry_run:
|
|
||||||
self.dbstat.add(self.backup_name,self.server_name,'',\
|
|
||||||
backup_start=start,backup_end = stop,status='OK',total_bytes=size_bytes,backup_location=dir_name)
|
|
||||||
else:
|
|
||||||
self.logger.info('Skipping %s, already registered',dir_name)
|
|
||||||
|
|
||||||
register_driver(backup_pgsql)
|
|
||||||
@@ -1,344 +0,0 @@
|
|||||||
#!/usr/bin/python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# This file is part of TISBackup
|
|
||||||
#
|
|
||||||
# TISBackup is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# TISBackup is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with TISBackup. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
#
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
import os
|
|
||||||
import datetime
|
|
||||||
from libtisbackup.common import *
|
|
||||||
import time
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
import os.path
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
|
|
||||||
class backup_rsync(backup_generic):
|
|
||||||
"""Backup a directory on remote server with rsync and rsync protocol (requires running remote rsync daemon)"""
|
|
||||||
type = 'rsync'
|
|
||||||
required_params = backup_generic.required_params + ['remote_user','remote_dir','rsync_module','password_file']
|
|
||||||
optional_params = backup_generic.optional_params + ['compressionlevel','compression','bwlimit','exclude_list','protect_args','overload_args']
|
|
||||||
|
|
||||||
remote_user='root'
|
|
||||||
remote_dir=''
|
|
||||||
|
|
||||||
exclude_list=''
|
|
||||||
rsync_module=''
|
|
||||||
password_file = ''
|
|
||||||
compression = ''
|
|
||||||
bwlimit = 0
|
|
||||||
protect_args = '1'
|
|
||||||
overload_args = None
|
|
||||||
compressionlevel = 0
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def read_config(self,iniconf):
|
|
||||||
assert(isinstance(iniconf,ConfigParser))
|
|
||||||
backup_generic.read_config(self,iniconf)
|
|
||||||
if not self.bwlimit and iniconf.has_option('global','bw_limit'):
|
|
||||||
self.bwlimit = iniconf.getint('global','bw_limit')
|
|
||||||
if not self.compressionlevel and iniconf.has_option('global','compression_level'):
|
|
||||||
self.compressionlevel = iniconf.getint('global','compression_level')
|
|
||||||
|
|
||||||
def do_backup(self,stats):
|
|
||||||
if not self.set_lock():
|
|
||||||
self.logger.error("[%s] a lock file is set, a backup maybe already running!!",self.backup_name)
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
try:
|
|
||||||
backup_source = 'undefined'
|
|
||||||
dest_dir = os.path.join(self.backup_dir,self.backup_start_date+'.rsync/')
|
|
||||||
if not os.path.isdir(dest_dir):
|
|
||||||
if not self.dry_run:
|
|
||||||
os.makedirs(dest_dir)
|
|
||||||
else:
|
|
||||||
print(('mkdir "%s"' % dest_dir))
|
|
||||||
else:
|
|
||||||
raise Exception('backup destination directory already exists : %s' % dest_dir)
|
|
||||||
|
|
||||||
options = ['-rt','--stats','--delete-excluded','--numeric-ids','--delete-after']
|
|
||||||
if self.logger.level:
|
|
||||||
options.append('-P')
|
|
||||||
|
|
||||||
if self.dry_run:
|
|
||||||
options.append('-d')
|
|
||||||
|
|
||||||
if self.overload_args != None:
|
|
||||||
options.append(self.overload_args)
|
|
||||||
elif not "cygdrive" in self.remote_dir:
|
|
||||||
# we don't preserve owner, group, links, hardlinks, perms for windows/cygwin as it is not reliable nor useful
|
|
||||||
options.append('-lpgoD')
|
|
||||||
|
|
||||||
# the protect-args option is not available in all rsync version
|
|
||||||
if not self.protect_args.lower() in ('false','no','0'):
|
|
||||||
options.append('--protect-args')
|
|
||||||
|
|
||||||
if self.compression.lower() in ('true','yes','1'):
|
|
||||||
options.append('-z')
|
|
||||||
|
|
||||||
if self.compressionlevel:
|
|
||||||
options.append('--compress-level=%s' % self.compressionlevel)
|
|
||||||
|
|
||||||
if self.bwlimit:
|
|
||||||
options.append('--bwlimit %s' % self.bwlimit)
|
|
||||||
|
|
||||||
latest = self.get_latest_backup(self.backup_start_date)
|
|
||||||
if latest:
|
|
||||||
options.extend(['--link-dest="%s"' % os.path.join('..',b,'') for b in latest])
|
|
||||||
|
|
||||||
def strip_quotes(s):
|
|
||||||
if s[0] == '"':
|
|
||||||
s = s[1:]
|
|
||||||
if s[-1] == '"':
|
|
||||||
s = s[:-1]
|
|
||||||
return s
|
|
||||||
|
|
||||||
# Add excludes
|
|
||||||
if "--exclude" in self.exclude_list:
|
|
||||||
# old settings with exclude_list=--exclude toto --exclude=titi
|
|
||||||
excludes = [strip_quotes(s).strip() for s in self.exclude_list.replace('--exclude=','').replace('--exclude ','').split()]
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
# newsettings with exclude_list='too','titi', parsed as a str python list content
|
|
||||||
excludes = eval('[%s]' % self.exclude_list)
|
|
||||||
except Exception as e:
|
|
||||||
raise Exception('Error reading exclude list : value %s, eval error %s (don\'t forget quotes and comma...)' % (self.exclude_list,e))
|
|
||||||
options.extend(['--exclude="%s"' % x for x in excludes])
|
|
||||||
|
|
||||||
if (self.rsync_module and not self.password_file):
|
|
||||||
raise Exception('You must specify a password file if you specify a rsync module')
|
|
||||||
|
|
||||||
if (not self.rsync_module and not self.private_key):
|
|
||||||
raise Exception('If you don''t use SSH, you must specify a rsync module')
|
|
||||||
|
|
||||||
#rsync_re = re.compile('(?P<server>[^:]*)::(?P<export>[^/]*)/(?P<path>.*)')
|
|
||||||
#ssh_re = re.compile('((?P<user>.*)@)?(?P<server>[^:]*):(?P<path>/.*)')
|
|
||||||
|
|
||||||
# Add ssh connection params
|
|
||||||
if self.rsync_module:
|
|
||||||
# Case of rsync exports
|
|
||||||
if self.password_file:
|
|
||||||
options.append('--password-file="%s"' % self.password_file)
|
|
||||||
backup_source = '%s@%s::%s%s' % (self.remote_user, self.server_name, self.rsync_module, self.remote_dir)
|
|
||||||
else:
|
|
||||||
# case of rsync + ssh
|
|
||||||
ssh_params = ['-o StrictHostKeyChecking=no']
|
|
||||||
ssh_params.append('-o BatchMode=yes')
|
|
||||||
|
|
||||||
if self.private_key:
|
|
||||||
ssh_params.append('-i %s' % self.private_key)
|
|
||||||
if self.cipher_spec:
|
|
||||||
ssh_params.append('-c %s' % self.cipher_spec)
|
|
||||||
if self.ssh_port != 22:
|
|
||||||
ssh_params.append('-p %i' % self.ssh_port)
|
|
||||||
options.append('-e "/usr/bin/ssh %s"' % (" ".join(ssh_params)))
|
|
||||||
backup_source = '%s@%s:%s' % (self.remote_user,self.server_name,self.remote_dir)
|
|
||||||
|
|
||||||
# ensure there is a slash at end
|
|
||||||
if backup_source[-1] != '/':
|
|
||||||
backup_source += '/'
|
|
||||||
|
|
||||||
options_params = " ".join(options)
|
|
||||||
|
|
||||||
cmd = '/usr/bin/rsync %s %s %s 2>&1' % (options_params,backup_source,dest_dir)
|
|
||||||
self.logger.debug("[%s] rsync : %s",self.backup_name,cmd)
|
|
||||||
|
|
||||||
if not self.dry_run:
|
|
||||||
self.line = ''
|
|
||||||
process = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, close_fds=True)
|
|
||||||
def ondata(data,context):
|
|
||||||
if context.verbose:
|
|
||||||
print(data)
|
|
||||||
context.logger.debug(data)
|
|
||||||
|
|
||||||
log = monitor_stdout(process,ondata,self)
|
|
||||||
|
|
||||||
reg_total_files = re.compile('Number of files: (?P<file>\d+)')
|
|
||||||
reg_transferred_files = re.compile('Number of .*files transferred: (?P<file>\d+)')
|
|
||||||
for l in log.splitlines():
|
|
||||||
line = l.replace(',','')
|
|
||||||
m = reg_total_files.match(line)
|
|
||||||
if m:
|
|
||||||
stats['total_files_count'] += int(m.groupdict()['file'])
|
|
||||||
m = reg_transferred_files.match(line)
|
|
||||||
if m:
|
|
||||||
stats['written_files_count'] += int(m.groupdict()['file'])
|
|
||||||
if line.startswith('Total file size:'):
|
|
||||||
stats['total_bytes'] += int(line.split(':')[1].split()[0])
|
|
||||||
if line.startswith('Total transferred file size:'):
|
|
||||||
stats['written_bytes'] += int(line.split(':')[1].split()[0])
|
|
||||||
|
|
||||||
returncode = process.returncode
|
|
||||||
## deal with exit code 24 (file vanished)
|
|
||||||
if (returncode == 24):
|
|
||||||
self.logger.warning("[" + self.backup_name + "] Note: some files vanished before transfer")
|
|
||||||
elif (returncode == 23):
|
|
||||||
self.logger.warning("[" + self.backup_name + "] unable so set uid on some files")
|
|
||||||
elif (returncode != 0):
|
|
||||||
self.logger.error("[" + self.backup_name + "] shell program exited with error code " + str(returncode))
|
|
||||||
raise Exception("[" + self.backup_name + "] shell program exited with error code " + str(returncode), cmd, log[-512:])
|
|
||||||
else:
|
|
||||||
print(cmd)
|
|
||||||
|
|
||||||
#we suppress the .rsync suffix if everything went well
|
|
||||||
finaldest = os.path.join(self.backup_dir,self.backup_start_date)
|
|
||||||
self.logger.debug("[%s] renaming target directory from %s to %s" ,self.backup_name,dest_dir,finaldest)
|
|
||||||
if not self.dry_run:
|
|
||||||
os.rename(dest_dir, finaldest)
|
|
||||||
self.logger.debug("[%s] touching datetime of target directory %s" ,self.backup_name,finaldest)
|
|
||||||
print((os.popen('touch "%s"' % finaldest).read()))
|
|
||||||
else:
|
|
||||||
print(("mv" ,dest_dir,finaldest))
|
|
||||||
stats['backup_location'] = finaldest
|
|
||||||
stats['status']='OK'
|
|
||||||
stats['log']='ssh+rsync backup from %s OK, %d bytes written for %d changed files' % (backup_source,stats['written_bytes'],stats['written_files_count'])
|
|
||||||
|
|
||||||
except BaseException as e:
|
|
||||||
stats['status']='ERROR'
|
|
||||||
stats['log']=str(e)
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
finally:
|
|
||||||
self.remove_lock()
|
|
||||||
|
|
||||||
def get_latest_backup(self,current):
|
|
||||||
result = []
|
|
||||||
filelist = os.listdir(self.backup_dir)
|
|
||||||
filelist.sort()
|
|
||||||
filelist.reverse()
|
|
||||||
full = ''
|
|
||||||
r_full = re.compile('^\d{8,8}-\d{2,2}h\d{2,2}m\d{2,2}$')
|
|
||||||
r_partial = re.compile('^\d{8,8}-\d{2,2}h\d{2,2}m\d{2,2}.rsync$')
|
|
||||||
# we take all latest partials younger than the latest full and the latest full
|
|
||||||
for item in filelist:
|
|
||||||
if r_partial.match(item) and item<current:
|
|
||||||
result.append(item)
|
|
||||||
elif r_full.match(item) and item<current:
|
|
||||||
result.append(item)
|
|
||||||
break
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def register_existingbackups(self):
|
|
||||||
"""scan backup dir and insert stats in database"""
|
|
||||||
|
|
||||||
registered = [b['backup_location'] for b in self.dbstat.query('select distinct backup_location from stats where backup_name=?',(self.backup_name,))]
|
|
||||||
|
|
||||||
filelist = os.listdir(self.backup_dir)
|
|
||||||
filelist.sort()
|
|
||||||
p = re.compile('^\d{8,8}-\d{2,2}h\d{2,2}m\d{2,2}$')
|
|
||||||
for item in filelist:
|
|
||||||
if p.match(item):
|
|
||||||
dir_name = os.path.join(self.backup_dir,item)
|
|
||||||
if not dir_name in registered:
|
|
||||||
start = datetime.datetime.strptime(item,'%Y%m%d-%Hh%Mm%S').isoformat()
|
|
||||||
if fileisodate(dir_name)>start:
|
|
||||||
stop = fileisodate(dir_name)
|
|
||||||
else:
|
|
||||||
stop = start
|
|
||||||
self.logger.info('Registering %s started on %s',dir_name,start)
|
|
||||||
self.logger.debug(' Disk usage %s','du -sb "%s"' % dir_name)
|
|
||||||
if not self.dry_run:
|
|
||||||
size_bytes = int(os.popen('du -sb "%s"' % dir_name).read().split('\t')[0])
|
|
||||||
else:
|
|
||||||
size_bytes = 0
|
|
||||||
self.logger.debug(' Size in bytes : %i',size_bytes)
|
|
||||||
if not self.dry_run:
|
|
||||||
self.dbstat.add(self.backup_name,self.server_name,'',\
|
|
||||||
backup_start=start,backup_end = stop,status='OK',total_bytes=size_bytes,backup_location=dir_name)
|
|
||||||
else:
|
|
||||||
self.logger.info('Skipping %s, already registered',dir_name)
|
|
||||||
|
|
||||||
|
|
||||||
def is_pid_still_running(self,lockfile):
|
|
||||||
f = open(lockfile)
|
|
||||||
lines = f.readlines()
|
|
||||||
f.close()
|
|
||||||
if len(lines)==0 :
|
|
||||||
self.logger.info("[" + self.backup_name + "] empty lock file, removing...")
|
|
||||||
return False
|
|
||||||
|
|
||||||
for line in lines:
|
|
||||||
if line.startswith('pid='):
|
|
||||||
pid = line.split('=')[1].strip()
|
|
||||||
if os.path.exists("/proc/" + pid):
|
|
||||||
self.logger.info("[" + self.backup_name + "] process still there")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
self.logger.info("[" + self.backup_name + "] process not there anymore remove lock")
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
self.logger.info("[" + self.backup_name + "] incorrrect lock file : no pid line")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def set_lock(self):
|
|
||||||
self.logger.debug("[" + self.backup_name + "] setting lock")
|
|
||||||
|
|
||||||
#TODO: improve for race condition
|
|
||||||
#TODO: also check if process is really there
|
|
||||||
if os.path.isfile(self.backup_dir + '/lock'):
|
|
||||||
self.logger.debug("[" + self.backup_name + "] File " + self.backup_dir + '/lock already exist')
|
|
||||||
if self.is_pid_still_running(self.backup_dir + '/lock')==False:
|
|
||||||
self.logger.info("[" + self.backup_name + "] removing lock file " + self.backup_dir + '/lock')
|
|
||||||
os.unlink(self.backup_dir + '/lock')
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
|
|
||||||
lockfile = open(self.backup_dir + '/lock',"w")
|
|
||||||
# Write all the lines at once:
|
|
||||||
lockfile.write('pid='+str(os.getpid()))
|
|
||||||
lockfile.write('\nbackup_time=' + self.backup_start_date)
|
|
||||||
lockfile.close()
|
|
||||||
return True
|
|
||||||
|
|
||||||
def remove_lock(self):
|
|
||||||
self.logger.debug("[%s] removing lock",self.backup_name )
|
|
||||||
os.unlink(self.backup_dir + '/lock')
|
|
||||||
|
|
||||||
class backup_rsync_ssh(backup_rsync):
|
|
||||||
"""Backup a directory on remote server with rsync and ssh protocol (requires rsync software on remote host)"""
|
|
||||||
type = 'rsync+ssh'
|
|
||||||
required_params = backup_generic.required_params + ['remote_user','remote_dir','private_key']
|
|
||||||
optional_params = backup_generic.optional_params + ['compression','bwlimit','ssh_port','exclude_list','protect_args','overload_args', 'cipher_spec']
|
|
||||||
cipher_spec = ''
|
|
||||||
|
|
||||||
|
|
||||||
register_driver(backup_rsync)
|
|
||||||
register_driver(backup_rsync_ssh)
|
|
||||||
|
|
||||||
if __name__=='__main__':
|
|
||||||
logger = logging.getLogger('tisbackup')
|
|
||||||
logger.setLevel(logging.DEBUG)
|
|
||||||
formatter = logging.Formatter('%(asctime)s %(levelname)s %(message)s')
|
|
||||||
handler = logging.StreamHandler()
|
|
||||||
handler.setFormatter(formatter)
|
|
||||||
logger.addHandler(handler)
|
|
||||||
|
|
||||||
cp = ConfigParser()
|
|
||||||
cp.read('/opt/tisbackup/configtest.ini')
|
|
||||||
dbstat = BackupStat('/backup/data/log/tisbackup.sqlite')
|
|
||||||
b = backup_rsync('htouvet','/backup/data/htouvet',dbstat)
|
|
||||||
b.read_config(cp)
|
|
||||||
b.process_backup()
|
|
||||||
print((b.checknagios()))
|
|
||||||
|
|
||||||
@@ -1,362 +0,0 @@
|
|||||||
#!/usr/bin/python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# This file is part of TISBackup
|
|
||||||
#
|
|
||||||
# TISBackup is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# TISBackup is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with TISBackup. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
#
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
import os
|
|
||||||
import datetime
|
|
||||||
from .common import *
|
|
||||||
import time
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
import os.path
|
|
||||||
import datetime
|
|
||||||
from .common import *
|
|
||||||
|
|
||||||
|
|
||||||
class backup_rsync_btrfs(backup_generic):
|
|
||||||
"""Backup a directory on remote server with rsync and btrfs protocol (requires running remote rsync daemon)"""
|
|
||||||
type = 'rsync+btrfs'
|
|
||||||
required_params = backup_generic.required_params + ['remote_user','remote_dir','rsync_module','password_file']
|
|
||||||
optional_params = backup_generic.optional_params + ['compressionlevel','compression','bwlimit','exclude_list','protect_args','overload_args']
|
|
||||||
|
|
||||||
remote_user='root'
|
|
||||||
remote_dir=''
|
|
||||||
|
|
||||||
exclude_list=''
|
|
||||||
rsync_module=''
|
|
||||||
password_file = ''
|
|
||||||
compression = ''
|
|
||||||
bwlimit = 0
|
|
||||||
protect_args = '1'
|
|
||||||
overload_args = None
|
|
||||||
compressionlevel = 0
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def read_config(self,iniconf):
|
|
||||||
assert(isinstance(iniconf,ConfigParser))
|
|
||||||
backup_generic.read_config(self,iniconf)
|
|
||||||
if not self.bwlimit and iniconf.has_option('global','bw_limit'):
|
|
||||||
self.bwlimit = iniconf.getint('global','bw_limit')
|
|
||||||
if not self.compressionlevel and iniconf.has_option('global','compression_level'):
|
|
||||||
self.compressionlevel = iniconf.getint('global','compression_level')
|
|
||||||
|
|
||||||
def do_backup(self,stats):
|
|
||||||
if not self.set_lock():
|
|
||||||
self.logger.error("[%s] a lock file is set, a backup maybe already running!!",self.backup_name)
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
try:
|
|
||||||
backup_source = 'undefined'
|
|
||||||
dest_dir = os.path.join(self.backup_dir,'last_backup')
|
|
||||||
if not os.path.isdir(dest_dir):
|
|
||||||
if not self.dry_run:
|
|
||||||
cmd = "/bin/btrfs subvolume create %s"%dest_dir
|
|
||||||
process = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, close_fds=True)
|
|
||||||
log = monitor_stdout(process,'',self)
|
|
||||||
returncode = process.returncode
|
|
||||||
if (returncode != 0):
|
|
||||||
self.logger.error("[" + self.backup_name + "] shell program exited with error code: %s"%log)
|
|
||||||
raise Exception("[" + self.backup_name + "] shell program exited with error code " + str(returncode), cmd)
|
|
||||||
else:
|
|
||||||
self.logger.info("[" + self.backup_name + "] create btrs volume: %s"%dest_dir)
|
|
||||||
else:
|
|
||||||
print(('btrfs subvolume create "%s"' %dest_dir))
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
options = ['-rt','--stats','--delete-excluded','--numeric-ids','--delete-after']
|
|
||||||
if self.logger.level:
|
|
||||||
options.append('-P')
|
|
||||||
|
|
||||||
if self.dry_run:
|
|
||||||
options.append('-d')
|
|
||||||
|
|
||||||
if self.overload_args != None:
|
|
||||||
options.append(self.overload_args)
|
|
||||||
elif not "cygdrive" in self.remote_dir:
|
|
||||||
# we don't preserve owner, group, links, hardlinks, perms for windows/cygwin as it is not reliable nor useful
|
|
||||||
options.append('-lpgoD')
|
|
||||||
|
|
||||||
# the protect-args option is not available in all rsync version
|
|
||||||
if not self.protect_args.lower() in ('false','no','0'):
|
|
||||||
options.append('--protect-args')
|
|
||||||
|
|
||||||
if self.compression.lower() in ('true','yes','1'):
|
|
||||||
options.append('-z')
|
|
||||||
|
|
||||||
if self.compressionlevel:
|
|
||||||
options.append('--compress-level=%s' % self.compressionlevel)
|
|
||||||
|
|
||||||
if self.bwlimit:
|
|
||||||
options.append('--bwlimit %s' % self.bwlimit)
|
|
||||||
|
|
||||||
latest = self.get_latest_backup(self.backup_start_date)
|
|
||||||
#remove link-dest replace by btrfs
|
|
||||||
#if latest:
|
|
||||||
# options.extend(['--link-dest="%s"' % os.path.join('..',b,'') for b in latest])
|
|
||||||
|
|
||||||
def strip_quotes(s):
|
|
||||||
if s[0] == '"':
|
|
||||||
s = s[1:]
|
|
||||||
if s[-1] == '"':
|
|
||||||
s = s[:-1]
|
|
||||||
return s
|
|
||||||
|
|
||||||
# Add excludes
|
|
||||||
if "--exclude" in self.exclude_list:
|
|
||||||
# old settings with exclude_list=--exclude toto --exclude=titi
|
|
||||||
excludes = [strip_quotes(s).strip() for s in self.exclude_list.replace('--exclude=','').replace('--exclude ','').split()]
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
# newsettings with exclude_list='too','titi', parsed as a str python list content
|
|
||||||
excludes = eval('[%s]' % self.exclude_list)
|
|
||||||
except Exception as e:
|
|
||||||
raise Exception('Error reading exclude list : value %s, eval error %s (don\'t forget quotes and comma...)' % (self.exclude_list,e))
|
|
||||||
options.extend(['--exclude="%s"' % x for x in excludes])
|
|
||||||
|
|
||||||
if (self.rsync_module and not self.password_file):
|
|
||||||
raise Exception('You must specify a password file if you specify a rsync module')
|
|
||||||
|
|
||||||
if (not self.rsync_module and not self.private_key):
|
|
||||||
raise Exception('If you don''t use SSH, you must specify a rsync module')
|
|
||||||
|
|
||||||
#rsync_re = re.compile('(?P<server>[^:]*)::(?P<export>[^/]*)/(?P<path>.*)')
|
|
||||||
#ssh_re = re.compile('((?P<user>.*)@)?(?P<server>[^:]*):(?P<path>/.*)')
|
|
||||||
|
|
||||||
# Add ssh connection params
|
|
||||||
if self.rsync_module:
|
|
||||||
# Case of rsync exports
|
|
||||||
if self.password_file:
|
|
||||||
options.append('--password-file="%s"' % self.password_file)
|
|
||||||
backup_source = '%s@%s::%s%s' % (self.remote_user, self.server_name, self.rsync_module, self.remote_dir)
|
|
||||||
else:
|
|
||||||
# case of rsync + ssh
|
|
||||||
ssh_params = ['-o StrictHostKeyChecking=no']
|
|
||||||
if self.private_key:
|
|
||||||
ssh_params.append('-i %s' % self.private_key)
|
|
||||||
if self.cipher_spec:
|
|
||||||
ssh_params.append('-c %s' % self.cipher_spec)
|
|
||||||
if self.ssh_port != 22:
|
|
||||||
ssh_params.append('-p %i' % self.ssh_port)
|
|
||||||
options.append('-e "/usr/bin/ssh %s"' % (" ".join(ssh_params)))
|
|
||||||
backup_source = '%s@%s:%s' % (self.remote_user,self.server_name,self.remote_dir)
|
|
||||||
|
|
||||||
# ensure there is a slash at end
|
|
||||||
if backup_source[-1] != '/':
|
|
||||||
backup_source += '/'
|
|
||||||
|
|
||||||
options_params = " ".join(options)
|
|
||||||
|
|
||||||
cmd = '/usr/bin/rsync %s %s %s 2>&1' % (options_params,backup_source,dest_dir)
|
|
||||||
self.logger.debug("[%s] rsync : %s",self.backup_name,cmd)
|
|
||||||
|
|
||||||
if not self.dry_run:
|
|
||||||
self.line = ''
|
|
||||||
process = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, close_fds=True)
|
|
||||||
def ondata(data,context):
|
|
||||||
if context.verbose:
|
|
||||||
print(data)
|
|
||||||
context.logger.debug(data)
|
|
||||||
|
|
||||||
log = monitor_stdout(process,ondata,self)
|
|
||||||
|
|
||||||
reg_total_files = re.compile('Number of files: (?P<file>\d+)')
|
|
||||||
reg_transferred_files = re.compile('Number of .*files transferred: (?P<file>\d+)')
|
|
||||||
for l in log.splitlines():
|
|
||||||
line = l.replace(',','')
|
|
||||||
m = reg_total_files.match(line)
|
|
||||||
if m:
|
|
||||||
stats['total_files_count'] += int(m.groupdict()['file'])
|
|
||||||
m = reg_transferred_files.match(line)
|
|
||||||
if m:
|
|
||||||
stats['written_files_count'] += int(m.groupdict()['file'])
|
|
||||||
if line.startswith('Total file size:'):
|
|
||||||
stats['total_bytes'] += int(line.split(':')[1].split()[0])
|
|
||||||
if line.startswith('Total transferred file size:'):
|
|
||||||
stats['written_bytes'] += int(line.split(':')[1].split()[0])
|
|
||||||
|
|
||||||
returncode = process.returncode
|
|
||||||
## deal with exit code 24 (file vanished)
|
|
||||||
if (returncode == 24):
|
|
||||||
self.logger.warning("[" + self.backup_name + "] Note: some files vanished before transfer")
|
|
||||||
elif (returncode == 23):
|
|
||||||
self.logger.warning("[" + self.backup_name + "] unable so set uid on some files")
|
|
||||||
elif (returncode != 0):
|
|
||||||
self.logger.error("[" + self.backup_name + "] shell program exited with error code ", str(returncode))
|
|
||||||
raise Exception("[" + self.backup_name + "] shell program exited with error code " + str(returncode), cmd, log[-512:])
|
|
||||||
else:
|
|
||||||
print(cmd)
|
|
||||||
|
|
||||||
#we take a snapshot of last_backup if everything went well
|
|
||||||
finaldest = os.path.join(self.backup_dir,self.backup_start_date)
|
|
||||||
self.logger.debug("[%s] snapshoting last_backup directory from %s to %s" ,self.backup_name,dest_dir,finaldest)
|
|
||||||
if not os.path.isdir(finaldest):
|
|
||||||
if not self.dry_run:
|
|
||||||
cmd = "/bin/btrfs subvolume snapshot %s %s"%(dest_dir,finaldest)
|
|
||||||
process = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, close_fds=True)
|
|
||||||
log = monitor_stdout(process,'',self)
|
|
||||||
returncode = process.returncode
|
|
||||||
if (returncode != 0):
|
|
||||||
self.logger.error("[" + self.backup_name + "] shell program exited with error code " + str(returncode))
|
|
||||||
raise Exception("[" + self.backup_name + "] shell program exited with error code " + str(returncode), cmd, log[-512:])
|
|
||||||
else:
|
|
||||||
self.logger.info("[" + self.backup_name + "] snapshot directory created %s"%finaldest)
|
|
||||||
else:
|
|
||||||
print(("btrfs snapshot of %s to %s"%(dest_dir,finaldest)))
|
|
||||||
else:
|
|
||||||
raise Exception('snapshot directory already exists : %s' %finaldest)
|
|
||||||
self.logger.debug("[%s] touching datetime of target directory %s" ,self.backup_name,finaldest)
|
|
||||||
print((os.popen('touch "%s"' % finaldest).read()))
|
|
||||||
stats['backup_location'] = finaldest
|
|
||||||
stats['status']='OK'
|
|
||||||
stats['log']='ssh+rsync+btrfs backup from %s OK, %d bytes written for %d changed files' % (backup_source,stats['written_bytes'],stats['written_files_count'])
|
|
||||||
|
|
||||||
except BaseException as e:
|
|
||||||
stats['status']='ERROR'
|
|
||||||
stats['log']=str(e)
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
finally:
|
|
||||||
self.remove_lock()
|
|
||||||
|
|
||||||
def get_latest_backup(self,current):
|
|
||||||
result = []
|
|
||||||
filelist = os.listdir(self.backup_dir)
|
|
||||||
filelist.sort()
|
|
||||||
filelist.reverse()
|
|
||||||
full = ''
|
|
||||||
r_full = re.compile('^\d{8,8}-\d{2,2}h\d{2,2}m\d{2,2}$')
|
|
||||||
r_partial = re.compile('^\d{8,8}-\d{2,2}h\d{2,2}m\d{2,2}.rsync$')
|
|
||||||
# we take all latest partials younger than the latest full and the latest full
|
|
||||||
for item in filelist:
|
|
||||||
if r_partial.match(item) and item<current:
|
|
||||||
result.append(item)
|
|
||||||
elif r_full.match(item) and item<current:
|
|
||||||
result.append(item)
|
|
||||||
break
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def register_existingbackups(self):
|
|
||||||
"""scan backup dir and insert stats in database"""
|
|
||||||
|
|
||||||
registered = [b['backup_location'] for b in self.dbstat.query('select distinct backup_location from stats where backup_name=?',(self.backup_name,))]
|
|
||||||
|
|
||||||
filelist = os.listdir(self.backup_dir)
|
|
||||||
filelist.sort()
|
|
||||||
p = re.compile('^\d{8,8}-\d{2,2}h\d{2,2}m\d{2,2}$')
|
|
||||||
for item in filelist:
|
|
||||||
if p.match(item):
|
|
||||||
dir_name = os.path.join(self.backup_dir,item)
|
|
||||||
if not dir_name in registered:
|
|
||||||
start = datetime.datetime.strptime(item,'%Y%m%d-%Hh%Mm%S').isoformat()
|
|
||||||
if fileisodate(dir_name)>start:
|
|
||||||
stop = fileisodate(dir_name)
|
|
||||||
else:
|
|
||||||
stop = start
|
|
||||||
self.logger.info('Registering %s started on %s',dir_name,start)
|
|
||||||
self.logger.debug(' Disk usage %s','du -sb "%s"' % dir_name)
|
|
||||||
if not self.dry_run:
|
|
||||||
size_bytes = int(os.popen('du -sb "%s"' % dir_name).read().split('\t')[0])
|
|
||||||
else:
|
|
||||||
size_bytes = 0
|
|
||||||
self.logger.debug(' Size in bytes : %i',size_bytes)
|
|
||||||
if not self.dry_run:
|
|
||||||
self.dbstat.add(self.backup_name,self.server_name,'',\
|
|
||||||
backup_start=start,backup_end = stop,status='OK',total_bytes=size_bytes,backup_location=dir_name)
|
|
||||||
else:
|
|
||||||
self.logger.info('Skipping %s, already registered',dir_name)
|
|
||||||
|
|
||||||
|
|
||||||
def is_pid_still_running(self,lockfile):
|
|
||||||
f = open(lockfile)
|
|
||||||
lines = f.readlines()
|
|
||||||
f.close()
|
|
||||||
if len(lines)==0 :
|
|
||||||
self.logger.info("[" + self.backup_name + "] empty lock file, removing...")
|
|
||||||
return False
|
|
||||||
|
|
||||||
for line in lines:
|
|
||||||
if line.startswith('pid='):
|
|
||||||
pid = line.split('=')[1].strip()
|
|
||||||
if os.path.exists("/proc/" + pid):
|
|
||||||
self.logger.info("[" + self.backup_name + "] process still there")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
self.logger.info("[" + self.backup_name + "] process not there anymore remove lock")
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
self.logger.info("[" + self.backup_name + "] incorrrect lock file : no pid line")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def set_lock(self):
|
|
||||||
self.logger.debug("[" + self.backup_name + "] setting lock")
|
|
||||||
|
|
||||||
#TODO: improve for race condition
|
|
||||||
#TODO: also check if process is really there
|
|
||||||
if os.path.isfile(self.backup_dir + '/lock'):
|
|
||||||
self.logger.debug("[" + self.backup_name + "] File " + self.backup_dir + '/lock already exist')
|
|
||||||
if self.is_pid_still_running(self.backup_dir + '/lock')==False:
|
|
||||||
self.logger.info("[" + self.backup_name + "] removing lock file " + self.backup_dir + '/lock')
|
|
||||||
os.unlink(self.backup_dir + '/lock')
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
|
|
||||||
lockfile = open(self.backup_dir + '/lock',"w")
|
|
||||||
# Write all the lines at once:
|
|
||||||
lockfile.write('pid='+str(os.getpid()))
|
|
||||||
lockfile.write('\nbackup_time=' + self.backup_start_date)
|
|
||||||
lockfile.close()
|
|
||||||
return True
|
|
||||||
|
|
||||||
def remove_lock(self):
|
|
||||||
self.logger.debug("[%s] removing lock",self.backup_name )
|
|
||||||
os.unlink(self.backup_dir + '/lock')
|
|
||||||
|
|
||||||
class backup_rsync__btrfs_ssh(backup_rsync_btrfs):
|
|
||||||
"""Backup a directory on remote server with rsync,ssh and btrfs protocol (requires rsync software on remote host)"""
|
|
||||||
type = 'rsync+btrfs+ssh'
|
|
||||||
required_params = backup_generic.required_params + ['remote_user','remote_dir','private_key']
|
|
||||||
optional_params = backup_generic.optional_params + ['compression','bwlimit','ssh_port','exclude_list','protect_args','overload_args','cipher_spec']
|
|
||||||
cipher_spec = ''
|
|
||||||
|
|
||||||
|
|
||||||
register_driver(backup_rsync_btrfs)
|
|
||||||
register_driver(backup_rsync__btrfs_ssh)
|
|
||||||
|
|
||||||
if __name__=='__main__':
|
|
||||||
logger = logging.getLogger('tisbackup')
|
|
||||||
logger.setLevel(logging.DEBUG)
|
|
||||||
formatter = logging.Formatter('%(asctime)s %(levelname)s %(message)s')
|
|
||||||
handler = logging.StreamHandler()
|
|
||||||
handler.setFormatter(formatter)
|
|
||||||
logger.addHandler(handler)
|
|
||||||
|
|
||||||
cp = ConfigParser()
|
|
||||||
cp.read('/opt/tisbackup/configtest.ini')
|
|
||||||
dbstat = BackupStat('/backup/data/log/tisbackup.sqlite')
|
|
||||||
b = backup_rsync('htouvet','/backup/data/htouvet',dbstat)
|
|
||||||
b.read_config(cp)
|
|
||||||
b.process_backup()
|
|
||||||
print((b.checknagios()))
|
|
||||||
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
#!/usr/bin/python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# This file is part of TISBackup
|
|
||||||
#
|
|
||||||
# TISBackup is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# TISBackup is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with TISBackup. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
#
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import sys
|
|
||||||
try:
|
|
||||||
sys.stderr = open('/dev/null') # Silence silly warnings from paramiko
|
|
||||||
import paramiko
|
|
||||||
except ImportError as e:
|
|
||||||
print("Error : can not load paramiko library %s" % e)
|
|
||||||
raise
|
|
||||||
|
|
||||||
sys.stderr = sys.__stderr__
|
|
||||||
|
|
||||||
from .common import *
|
|
||||||
|
|
||||||
class backup_samba4(backup_generic):
|
|
||||||
"""Backup a samba4 databases as gzipped tdbs file through ssh"""
|
|
||||||
type = 'samba4'
|
|
||||||
required_params = backup_generic.required_params + ['private_key']
|
|
||||||
optional_params = backup_generic.optional_params + ['root_dir_samba']
|
|
||||||
|
|
||||||
root_dir_samba = "/var/lib/samba/"
|
|
||||||
def do_backup(self,stats):
|
|
||||||
self.dest_dir = os.path.join(self.backup_dir,self.backup_start_date)
|
|
||||||
|
|
||||||
|
|
||||||
if not os.path.isdir(self.dest_dir):
|
|
||||||
if not self.dry_run:
|
|
||||||
os.makedirs(self.dest_dir)
|
|
||||||
else:
|
|
||||||
print('mkdir "%s"' % self.dest_dir)
|
|
||||||
else:
|
|
||||||
raise Exception('backup destination directory already exists : %s' % self.dest_dir)
|
|
||||||
|
|
||||||
self.logger.debug('[%s] Connecting to %s with user root and key %s',self.backup_name,self.server_name,self.private_key)
|
|
||||||
try:
|
|
||||||
mykey = paramiko.RSAKey.from_private_key_file(self.private_key)
|
|
||||||
except paramiko.SSHException:
|
|
||||||
#mykey = paramiko.DSSKey.from_private_key_file(self.private_key)
|
|
||||||
mykey = paramiko.Ed25519Key.from_private_key_file(self.private_key)
|
|
||||||
|
|
||||||
self.ssh = paramiko.SSHClient()
|
|
||||||
self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
||||||
self.ssh.connect(self.server_name,username='root',pkey = mykey, port=self.ssh_port)
|
|
||||||
|
|
||||||
stats['log']= "Successfully backuping processed to the following databases :"
|
|
||||||
stats['status']='List'
|
|
||||||
dir_ldbs = os.path.join(self.root_dir_samba+'/private/sam.ldb.d/')
|
|
||||||
cmd = 'ls %s/*.ldb 2> /dev/null' % dir_ldbs
|
|
||||||
self.logger.debug('[%s] List databases: %s',self.backup_name,cmd)
|
|
||||||
(error_code,output) = ssh_exec(cmd,ssh=self.ssh)
|
|
||||||
self.logger.debug("[%s] Output of %s :\n%s",self.backup_name,cmd,output)
|
|
||||||
if error_code:
|
|
||||||
raise Exception('Aborting, Not null exit code (%i) for "%s"' % (error_code,cmd))
|
|
||||||
databases = output.split('\n')
|
|
||||||
for database in databases:
|
|
||||||
if database != "":
|
|
||||||
self.db_name = database.rstrip()
|
|
||||||
self.do_mysqldump(stats)
|
|
||||||
|
|
||||||
|
|
||||||
def do_mysqldump(self,stats):
|
|
||||||
t = datetime.datetime.now()
|
|
||||||
backup_start_date = t.strftime('%Y%m%d-%Hh%Mm%S')
|
|
||||||
|
|
||||||
# dump db
|
|
||||||
stats['status']='Dumping'
|
|
||||||
cmd = 'tdbbackup -s .tisbackup ' + self.db_name
|
|
||||||
self.logger.debug('[%s] Dump DB : %s',self.backup_name,cmd)
|
|
||||||
if not self.dry_run:
|
|
||||||
(error_code,output) = ssh_exec(cmd,ssh=self.ssh)
|
|
||||||
print(output)
|
|
||||||
self.logger.debug("[%s] Output of %s :\n%s",self.backup_name,cmd,output)
|
|
||||||
if error_code:
|
|
||||||
raise Exception('Aborting, Not null exit code (%i) for "%s"' % (error_code,cmd))
|
|
||||||
|
|
||||||
# zip the file
|
|
||||||
stats['status']='Zipping'
|
|
||||||
cmd = 'gzip -f "%s.tisbackup"' % self.db_name
|
|
||||||
self.logger.debug('[%s] Compress backup : %s',self.backup_name,cmd)
|
|
||||||
if not self.dry_run:
|
|
||||||
(error_code,output) = ssh_exec(cmd,ssh=self.ssh)
|
|
||||||
self.logger.debug("[%s] Output of %s :\n%s",self.backup_name,cmd,output)
|
|
||||||
if error_code:
|
|
||||||
raise Exception('Aborting, Not null exit code (%i) for "%s"' % (error_code,cmd))
|
|
||||||
|
|
||||||
# get the file
|
|
||||||
stats['status']='SFTP'
|
|
||||||
filepath = self.db_name + '.tisbackup.gz'
|
|
||||||
localpath = os.path.join(self.dest_dir , os.path.basename(self.db_name) + '.tisbackup.gz')
|
|
||||||
self.logger.debug('[%s] Get gz backup with sftp on %s from %s to %s',self.backup_name,self.server_name,filepath,localpath)
|
|
||||||
if not self.dry_run:
|
|
||||||
transport = self.ssh.get_transport()
|
|
||||||
sftp = paramiko.SFTPClient.from_transport(transport)
|
|
||||||
sftp.get(filepath, localpath)
|
|
||||||
sftp.close()
|
|
||||||
|
|
||||||
if not self.dry_run:
|
|
||||||
stats['total_files_count']=1 + stats.get('total_files_count', 0)
|
|
||||||
stats['written_files_count']=1 + stats.get('written_files_count', 0)
|
|
||||||
stats['total_bytes']=os.stat(localpath).st_size + stats.get('total_bytes', 0)
|
|
||||||
stats['written_bytes']=os.stat(localpath).st_size + stats.get('written_bytes', 0)
|
|
||||||
stats['log'] = '%s "%s"' % (stats['log'] ,self.db_name)
|
|
||||||
stats['backup_location'] = self.dest_dir
|
|
||||||
|
|
||||||
stats['status']='RMTemp'
|
|
||||||
cmd = 'rm -f "%s"' % filepath
|
|
||||||
self.logger.debug('[%s] Remove temp gzip : %s',self.backup_name,cmd)
|
|
||||||
if not self.dry_run:
|
|
||||||
(error_code,output) = ssh_exec(cmd,ssh=self.ssh)
|
|
||||||
self.logger.debug("[%s] Output of %s :\n%s",self.backup_name,cmd,output)
|
|
||||||
if error_code:
|
|
||||||
raise Exception('Aborting, Not null exit code (%i) for "%s"' % (error_code,cmd))
|
|
||||||
stats['status']='OK'
|
|
||||||
|
|
||||||
def register_existingbackups(self):
|
|
||||||
"""scan backup dir and insert stats in database"""
|
|
||||||
|
|
||||||
registered = [b['backup_location'] for b in self.dbstat.query('select distinct backup_location from stats where backup_name=?',(self.backup_name,))]
|
|
||||||
|
|
||||||
filelist = os.listdir(self.backup_dir)
|
|
||||||
filelist.sort()
|
|
||||||
p = re.compile('^\d{8,8}-\d{2,2}h\d{2,2}m\d{2,2}$')
|
|
||||||
for item in filelist:
|
|
||||||
if p.match(item):
|
|
||||||
dir_name = os.path.join(self.backup_dir,item)
|
|
||||||
if not dir_name in registered:
|
|
||||||
start = datetime.datetime.strptime(item,'%Y%m%d-%Hh%Mm%S').isoformat()
|
|
||||||
if fileisodate(dir_name)>start:
|
|
||||||
stop = fileisodate(dir_name)
|
|
||||||
else:
|
|
||||||
stop = start
|
|
||||||
self.logger.info('Registering %s started on %s',dir_name,start)
|
|
||||||
self.logger.debug(' Disk usage %s','du -sb "%s"' % dir_name)
|
|
||||||
if not self.dry_run:
|
|
||||||
size_bytes = int(os.popen('du -sb "%s"' % dir_name).read().split('\t')[0])
|
|
||||||
else:
|
|
||||||
size_bytes = 0
|
|
||||||
self.logger.debug(' Size in bytes : %i',size_bytes)
|
|
||||||
if not self.dry_run:
|
|
||||||
self.dbstat.add(self.backup_name,self.server_name,'',\
|
|
||||||
backup_start=start,backup_end = stop,status='OK',total_bytes=size_bytes,backup_location=dir_name)
|
|
||||||
else:
|
|
||||||
self.logger.info('Skipping %s, already registered',dir_name)
|
|
||||||
|
|
||||||
|
|
||||||
register_driver(backup_samba4)
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
#!/usr/bin/python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# This file is part of TISBackup
|
|
||||||
#
|
|
||||||
# TISBackup is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# TISBackup is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with TISBackup. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
#
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import sys
|
|
||||||
try:
|
|
||||||
sys.stderr = open('/dev/null') # Silence silly warnings from paramiko
|
|
||||||
import paramiko
|
|
||||||
except ImportError as e:
|
|
||||||
print("Error : can not load paramiko library %s" % e)
|
|
||||||
raise
|
|
||||||
|
|
||||||
sys.stderr = sys.__stderr__
|
|
||||||
|
|
||||||
import datetime
|
|
||||||
import base64
|
|
||||||
import os
|
|
||||||
from .common import *
|
|
||||||
|
|
||||||
class backup_sqlserver(backup_generic):
|
|
||||||
"""Backup a SQLSERVER database as gzipped sql file through ssh"""
|
|
||||||
type = 'sqlserver+ssh'
|
|
||||||
required_params = backup_generic.required_params + ['db_name','private_key']
|
|
||||||
optional_params = ['username', 'remote_backup_dir', 'sqlserver_before_2005', 'db_server_name', 'db_user', 'db_password']
|
|
||||||
db_name=''
|
|
||||||
db_user=''
|
|
||||||
db_password=''
|
|
||||||
userdb = "-E"
|
|
||||||
username='Administrateur'
|
|
||||||
remote_backup_dir = r'c:/WINDOWS/Temp/'
|
|
||||||
sqlserver_before_2005 = False
|
|
||||||
db_server_name = "localhost"
|
|
||||||
|
|
||||||
|
|
||||||
def do_backup(self,stats):
|
|
||||||
|
|
||||||
try:
|
|
||||||
mykey = paramiko.RSAKey.from_private_key_file(self.private_key)
|
|
||||||
except paramiko.SSHException:
|
|
||||||
#mykey = paramiko.DSSKey.from_private_key_file(self.private_key)
|
|
||||||
mykey = paramiko.Ed25519Key.from_private_key_file(self.private_key)
|
|
||||||
|
|
||||||
self.logger.debug('[%s] Connecting to %s with user root and key %s',self.backup_name,self.server_name,self.private_key)
|
|
||||||
ssh = paramiko.SSHClient()
|
|
||||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
||||||
ssh.connect(self.server_name,username=self.username,pkey=mykey, port=self.ssh_port)
|
|
||||||
|
|
||||||
t = datetime.datetime.now()
|
|
||||||
backup_start_date = t.strftime('%Y%m%d-%Hh%Mm%S')
|
|
||||||
|
|
||||||
backup_file = self.remote_backup_dir + '/' + self.db_name + '-' + backup_start_date + '.bak'
|
|
||||||
if not self.db_user == '':
|
|
||||||
self.userdb = '-U %s -P %s' % ( self.db_user, self.db_password )
|
|
||||||
|
|
||||||
# dump db
|
|
||||||
stats['status']='Dumping'
|
|
||||||
if self.sqlserver_before_2005:
|
|
||||||
cmd = """osql -E -Q "BACKUP DATABASE [%s]
|
|
||||||
TO DISK='%s'
|
|
||||||
WITH FORMAT" """ % ( self.db_name, backup_file )
|
|
||||||
else:
|
|
||||||
cmd = """sqlcmd %s -S "%s" -d master -Q "BACKUP DATABASE [%s]
|
|
||||||
TO DISK = N'%s'
|
|
||||||
WITH INIT, NOUNLOAD ,
|
|
||||||
NAME = N'Backup %s', NOSKIP ,STATS = 10, NOFORMAT" """ % (self.userdb, self.db_server_name, self.db_name, backup_file ,self.db_name )
|
|
||||||
self.logger.debug('[%s] Dump DB : %s',self.backup_name,cmd)
|
|
||||||
try:
|
|
||||||
if not self.dry_run:
|
|
||||||
(error_code,output) = ssh_exec(cmd,ssh=ssh)
|
|
||||||
self.logger.debug("[%s] Output of %s :\n%s",self.backup_name,cmd,output)
|
|
||||||
if error_code:
|
|
||||||
raise Exception('Aborting, Not null exit code (%i) for "%s"' % (error_code,cmd))
|
|
||||||
|
|
||||||
# zip the file
|
|
||||||
stats['status']='Zipping'
|
|
||||||
cmd = 'gzip "%s"' % backup_file
|
|
||||||
self.logger.debug('[%s] Compress backup : %s',self.backup_name,cmd)
|
|
||||||
if not self.dry_run:
|
|
||||||
(error_code,output) = ssh_exec(cmd,ssh=ssh)
|
|
||||||
self.logger.debug("[%s] Output of %s :\n%s",self.backup_name,cmd,output)
|
|
||||||
if error_code:
|
|
||||||
raise Exception('Aborting, Not null exit code (%i) for "%s"' % (error_code,cmd))
|
|
||||||
|
|
||||||
# get the file
|
|
||||||
stats['status']='SFTP'
|
|
||||||
filepath = backup_file + '.gz'
|
|
||||||
localpath = os.path.join(self.backup_dir , self.db_name + '-' + backup_start_date + '.bak.gz')
|
|
||||||
self.logger.debug('[%s] Get gz backup with sftp on %s from %s to %s',self.backup_name,self.server_name,filepath,localpath)
|
|
||||||
if not self.dry_run:
|
|
||||||
transport = ssh.get_transport()
|
|
||||||
sftp = paramiko.SFTPClient.from_transport(transport)
|
|
||||||
sftp.get(filepath, localpath)
|
|
||||||
sftp.close()
|
|
||||||
|
|
||||||
if not self.dry_run:
|
|
||||||
stats['total_files_count']=1
|
|
||||||
stats['written_files_count']=1
|
|
||||||
stats['total_bytes']=os.stat(localpath).st_size
|
|
||||||
stats['written_bytes']=os.stat(localpath).st_size
|
|
||||||
stats['log']='gzip dump of DB %s:%s (%d bytes) to %s' % (self.server_name,self.db_name, stats['written_bytes'], localpath)
|
|
||||||
stats['backup_location'] = localpath
|
|
||||||
|
|
||||||
finally:
|
|
||||||
stats['status']='RMTemp'
|
|
||||||
cmd = 'rm -f "%s" "%s"' % ( backup_file + '.gz', backup_file )
|
|
||||||
self.logger.debug('[%s] Remove temp gzip : %s',self.backup_name,cmd)
|
|
||||||
if not self.dry_run:
|
|
||||||
(error_code,output) = ssh_exec(cmd,ssh=ssh)
|
|
||||||
self.logger.debug("[%s] Output of %s :\n%s",self.backup_name,cmd,output)
|
|
||||||
if error_code:
|
|
||||||
raise Exception('Aborting, Not null exit code (%i) for "%s"' % (error_code,cmd))
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
stats['status']='OK'
|
|
||||||
|
|
||||||
def register_existingbackups(self):
|
|
||||||
"""scan backup dir and insert stats in database"""
|
|
||||||
|
|
||||||
registered = [b['backup_location'] for b in self.dbstat.query('select distinct backup_location from stats where backup_name=?',(self.backup_name,))]
|
|
||||||
|
|
||||||
filelist = os.listdir(self.backup_dir)
|
|
||||||
filelist.sort()
|
|
||||||
p = re.compile('^%s-(?P<date>\d{8,8}-\d{2,2}h\d{2,2}m\d{2,2}).bak.gz$' % self.db_name)
|
|
||||||
for item in filelist:
|
|
||||||
sr = p.match(item)
|
|
||||||
if sr:
|
|
||||||
file_name = os.path.join(self.backup_dir,item)
|
|
||||||
start = datetime.datetime.strptime(sr.groups()[0],'%Y%m%d-%Hh%Mm%S').isoformat()
|
|
||||||
if not file_name in registered:
|
|
||||||
self.logger.info('Registering %s from %s',file_name,fileisodate(file_name))
|
|
||||||
size_bytes = int(os.popen('du -sb "%s"' % file_name).read().split('\t')[0])
|
|
||||||
self.logger.debug(' Size in bytes : %i',size_bytes)
|
|
||||||
if not self.dry_run:
|
|
||||||
self.dbstat.add(self.backup_name,self.server_name,'',\
|
|
||||||
backup_start=start,backup_end=fileisodate(file_name),status='OK',total_bytes=size_bytes,backup_location=file_name)
|
|
||||||
else:
|
|
||||||
self.logger.info('Skipping %s from %s, already registered',file_name,fileisodate(file_name))
|
|
||||||
|
|
||||||
register_driver(backup_sqlserver)
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
#!/usr/bin/python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# This file is part of TISBackup
|
|
||||||
#
|
|
||||||
# TISBackup is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# TISBackup is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with TISBackup. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
#
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
from .common import *
|
|
||||||
import paramiko
|
|
||||||
|
|
||||||
class backup_xcp_metadata(backup_generic):
|
|
||||||
"""Backup metatdata of a xcp pool using xe pool-dump-database"""
|
|
||||||
type = 'xcp-dump-metadata'
|
|
||||||
required_params = ['type','server_name','private_key','backup_name']
|
|
||||||
|
|
||||||
def do_backup(self,stats):
|
|
||||||
|
|
||||||
self.logger.debug('[%s] Connecting to %s with user root and key %s',self.backup_name,self.server_name,self.private_key)
|
|
||||||
|
|
||||||
|
|
||||||
t = datetime.datetime.now()
|
|
||||||
backup_start_date = t.strftime('%Y%m%d-%Hh%Mm%S')
|
|
||||||
|
|
||||||
# dump pool medatadata
|
|
||||||
localpath = os.path.join(self.backup_dir , 'xcp_metadata-' + backup_start_date + '.dump')
|
|
||||||
stats['status']='Dumping'
|
|
||||||
if not self.dry_run:
|
|
||||||
cmd = "/opt/xensource/bin/xe pool-dump-database file-name="
|
|
||||||
self.logger.debug('[%s] Dump XCP Metadata : %s', self.backup_name, cmd)
|
|
||||||
(error_code, output) = ssh_exec(cmd, server_name=self.server_name,private_key=self.private_key, remote_user='root')
|
|
||||||
|
|
||||||
with open(localpath,"w") as f:
|
|
||||||
f.write(output)
|
|
||||||
|
|
||||||
# zip the file
|
|
||||||
stats['status']='Zipping'
|
|
||||||
cmd = 'gzip %s ' % localpath
|
|
||||||
self.logger.debug('[%s] Compress backup : %s',self.backup_name,cmd)
|
|
||||||
if not self.dry_run:
|
|
||||||
call_external_process(cmd)
|
|
||||||
localpath += ".gz"
|
|
||||||
if not self.dry_run:
|
|
||||||
stats['total_files_count']=1
|
|
||||||
stats['written_files_count']=1
|
|
||||||
stats['total_bytes']=os.stat(localpath).st_size
|
|
||||||
stats['written_bytes']=os.stat(localpath).st_size
|
|
||||||
stats['log']='gzip dump of DB %s:%s (%d bytes) to %s' % (self.server_name,'xcp metadata dump', stats['written_bytes'], localpath)
|
|
||||||
stats['backup_location'] = localpath
|
|
||||||
stats['status']='OK'
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def register_existingbackups(self):
|
|
||||||
"""scan metatdata backup files and insert stats in database"""
|
|
||||||
|
|
||||||
registered = [b['backup_location'] for b in self.dbstat.query('select distinct backup_location from stats where backup_name=?',(self.backup_name,))]
|
|
||||||
|
|
||||||
filelist = os.listdir(self.backup_dir)
|
|
||||||
filelist.sort()
|
|
||||||
p = re.compile('^%s-(?P<date>\d{8,8}-\d{2,2}h\d{2,2}m\d{2,2}).dump.gz$' % self.server_name)
|
|
||||||
for item in filelist:
|
|
||||||
sr = p.match(item)
|
|
||||||
if sr:
|
|
||||||
file_name = os.path.join(self.backup_dir,item)
|
|
||||||
start = datetime.datetime.strptime(sr.groups()[0],'%Y%m%d-%Hh%Mm%S').isoformat()
|
|
||||||
if not file_name in registered:
|
|
||||||
self.logger.info('Registering %s from %s',file_name,fileisodate(file_name))
|
|
||||||
size_bytes = int(os.popen('du -sb "%s"' % file_name).read().split('\t')[0])
|
|
||||||
self.logger.debug(' Size in bytes : %i',size_bytes)
|
|
||||||
if not self.dry_run:
|
|
||||||
self.dbstat.add(self.backup_name,self.server_name,'',\
|
|
||||||
backup_start=start,backup_end=fileisodate(file_name),status='OK',total_bytes=size_bytes,backup_location=file_name)
|
|
||||||
else:
|
|
||||||
self.logger.info('Skipping %s from %s, already registered',file_name,fileisodate(file_name))
|
|
||||||
|
|
||||||
register_driver(backup_xcp_metadata)
|
|
||||||
@@ -1,277 +0,0 @@
|
|||||||
#!/usr/bin/python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# This file is part of TISBackup
|
|
||||||
#
|
|
||||||
# TISBackup is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# TISBackup is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with TISBackup. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
#
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
import os
|
|
||||||
import datetime
|
|
||||||
import urllib.request, urllib.parse, urllib.error
|
|
||||||
import socket
|
|
||||||
import tarfile
|
|
||||||
import hashlib
|
|
||||||
from stat import *
|
|
||||||
import ssl
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from .common import *
|
|
||||||
from . import XenAPI
|
|
||||||
|
|
||||||
if hasattr(ssl, '_create_unverified_context'):
|
|
||||||
ssl._create_default_https_context = ssl._create_unverified_context
|
|
||||||
|
|
||||||
|
|
||||||
class backup_xva(backup_generic):
|
|
||||||
"""Backup a VM running on a XCP server as a XVA file (requires xe tools and XenAPI)"""
|
|
||||||
type = 'xen-xva'
|
|
||||||
|
|
||||||
required_params = backup_generic.required_params + ['xcphost','password_file','server_name']
|
|
||||||
optional_params = backup_generic.optional_params + ['enable_https', 'halt_vm', 'verify_export', 'reuse_snapshot', 'ignore_proxies', 'use_compression' ]
|
|
||||||
|
|
||||||
enable_https = "no"
|
|
||||||
halt_vm = "no"
|
|
||||||
verify_export = "no"
|
|
||||||
reuse_snapshot = "no"
|
|
||||||
ignore_proxies = "yes"
|
|
||||||
use_compression = "true"
|
|
||||||
|
|
||||||
if str2bool(ignore_proxies):
|
|
||||||
os.environ['http_proxy']=""
|
|
||||||
os.environ['https_proxy']=""
|
|
||||||
|
|
||||||
def verify_export_xva(self,filename):
|
|
||||||
self.logger.debug("[%s] Verify xva export integrity",self.server_name)
|
|
||||||
tar = tarfile.open(filename)
|
|
||||||
members = tar.getmembers()
|
|
||||||
for tarinfo in members:
|
|
||||||
if re.search('^[0-9]*$',os.path.basename(tarinfo.name)):
|
|
||||||
sha1sum = hashlib.sha1(tar.extractfile(tarinfo).read()).hexdigest()
|
|
||||||
sha1sum2 = tar.extractfile(tarinfo.name+'.checksum').read()
|
|
||||||
if not sha1sum == sha1sum2:
|
|
||||||
raise Exception("File corrupt")
|
|
||||||
tar.close()
|
|
||||||
|
|
||||||
def export_xva(self, vdi_name, filename, halt_vm,dry_run,enable_https=True, reuse_snapshot="no"):
|
|
||||||
|
|
||||||
user_xen, password_xen, null = open(self.password_file).read().split('\n')
|
|
||||||
session = XenAPI.Session('https://'+self.xcphost)
|
|
||||||
try:
|
|
||||||
session.login_with_password(user_xen,password_xen)
|
|
||||||
except XenAPI.Failure as error:
|
|
||||||
msg,ip = error.details
|
|
||||||
|
|
||||||
if msg == 'HOST_IS_SLAVE':
|
|
||||||
xcphost = ip
|
|
||||||
session = XenAPI.Session('https://'+xcphost)
|
|
||||||
session.login_with_password(user_xen,password_xen)
|
|
||||||
|
|
||||||
if not session.xenapi.VM.get_by_name_label(vdi_name):
|
|
||||||
return "bad VM name: %s" % vdi_name
|
|
||||||
|
|
||||||
vm = session.xenapi.VM.get_by_name_label(vdi_name)[0]
|
|
||||||
status_vm = session.xenapi.VM.get_power_state(vm)
|
|
||||||
|
|
||||||
self.logger.debug("[%s] Check if previous fail backups exist",vdi_name)
|
|
||||||
backups_fail = files = [f for f in os.listdir(self.backup_dir) if f.startswith(vdi_name) and f.endswith(".tmp")]
|
|
||||||
for backup_fail in backups_fail:
|
|
||||||
self.logger.debug('[%s] Delete backup "%s"', vdi_name, backup_fail)
|
|
||||||
os.unlink(os.path.join(self.backup_dir, backup_fail))
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#add snapshot option
|
|
||||||
if not str2bool(halt_vm):
|
|
||||||
self.logger.debug("[%s] Check if previous tisbackups snapshots exist",vdi_name)
|
|
||||||
old_snapshots = session.xenapi.VM.get_by_name_label("tisbackup-%s"%(vdi_name))
|
|
||||||
self.logger.debug("[%s] Old snaps count %s", vdi_name, len(old_snapshots))
|
|
||||||
|
|
||||||
if len(old_snapshots) == 1 and str2bool(reuse_snapshot) == True:
|
|
||||||
snapshot = old_snapshots[0]
|
|
||||||
self.logger.debug("[%s] Reusing snap \"%s\"", vdi_name, session.xenapi.VM.get_name_description(snapshot))
|
|
||||||
vm = snapshot # vm = session.xenapi.VM.get_by_name_label("tisbackup-%s"%(vdi_name))[0]
|
|
||||||
else:
|
|
||||||
self.logger.debug("[%s] Deleting %s old snaps", vdi_name, len(old_snapshots))
|
|
||||||
for old_snapshot in old_snapshots:
|
|
||||||
self.logger.debug("[%s] Destroy snapshot %s",vdi_name,session.xenapi.VM.get_name_description(old_snapshot))
|
|
||||||
try:
|
|
||||||
for vbd in session.xenapi.VM.get_VBDs(old_snapshot):
|
|
||||||
if session.xenapi.VBD.get_type(vbd) == 'CD' and session.xenapi.VBD.get_record(vbd)['empty'] == False:
|
|
||||||
session.xenapi.VBD.eject(vbd)
|
|
||||||
else:
|
|
||||||
vdi = session.xenapi.VBD.get_VDI(vbd)
|
|
||||||
if not 'NULL' in vdi:
|
|
||||||
session.xenapi.VDI.destroy(vdi)
|
|
||||||
session.xenapi.VM.destroy(old_snapshot)
|
|
||||||
except XenAPI.Failure as error:
|
|
||||||
return("error when destroy snapshot %s"%(error))
|
|
||||||
|
|
||||||
now = datetime.datetime.now()
|
|
||||||
self.logger.debug("[%s] Snapshot in progress",vdi_name)
|
|
||||||
try:
|
|
||||||
snapshot = session.xenapi.VM.snapshot(vm,"tisbackup-%s"%(vdi_name))
|
|
||||||
self.logger.debug("[%s] got snapshot %s", vdi_name, snapshot)
|
|
||||||
except XenAPI.Failure as error:
|
|
||||||
return("error when snapshot %s"%(error))
|
|
||||||
#get snapshot opaqueRef
|
|
||||||
vm = session.xenapi.VM.get_by_name_label("tisbackup-%s"%(vdi_name))[0]
|
|
||||||
session.xenapi.VM.set_name_description(snapshot,"snapshot created by tisbackup on: %s"%(now.strftime("%Y-%m-%d %H:%M")))
|
|
||||||
else:
|
|
||||||
self.logger.debug("[%s] Status of VM: %s",self.backup_name,status_vm)
|
|
||||||
if status_vm == "Running":
|
|
||||||
self.logger.debug("[%s] Shudown in progress",self.backup_name)
|
|
||||||
if dry_run:
|
|
||||||
print("session.xenapi.VM.clean_shutdown(vm)")
|
|
||||||
else:
|
|
||||||
session.xenapi.VM.clean_shutdown(vm)
|
|
||||||
try:
|
|
||||||
try:
|
|
||||||
filename_temp = filename+".tmp"
|
|
||||||
self.logger.debug("[%s] Copy in progress",self.backup_name)
|
|
||||||
if not str2bool(self.use_compression):
|
|
||||||
socket.setdefaulttimeout(120)
|
|
||||||
|
|
||||||
scheme = "http://"
|
|
||||||
if str2bool(enable_https):
|
|
||||||
scheme = "https://"
|
|
||||||
url = scheme+user_xen+":"+password_xen+"@"+self.xcphost+"/export?use_compression="+self.use_compression+"&uuid="+session.xenapi.VM.get_uuid(vm)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
top_level_url = scheme+self.xcphost+"/export?use_compression="+self.use_compression+"&uuid="+session.xenapi.VM.get_uuid(vm)
|
|
||||||
r = requests.get(top_level_url, auth=(user_xen, password_xen))
|
|
||||||
open(filename_temp, 'wb').write(r.content)
|
|
||||||
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error("[%s] error when fetching snap: %s", "tisbackup-%s"%(vdi_name), e)
|
|
||||||
if os.path.exists(filename_temp):
|
|
||||||
os.unlink(filename_temp)
|
|
||||||
raise
|
|
||||||
|
|
||||||
finally:
|
|
||||||
if not str2bool(halt_vm):
|
|
||||||
self.logger.debug("[%s] Destroy snapshot",'tisbackup-%s'%(vdi_name))
|
|
||||||
try:
|
|
||||||
for vbd in session.xenapi.VM.get_VBDs(snapshot):
|
|
||||||
if session.xenapi.VBD.get_type(vbd) == 'CD' and session.xenapi.VBD.get_record(vbd)['empty'] == False:
|
|
||||||
session.xenapi.VBD.eject(vbd)
|
|
||||||
else:
|
|
||||||
vdi = session.xenapi.VBD.get_VDI(vbd)
|
|
||||||
if not 'NULL' in vdi:
|
|
||||||
session.xenapi.VDI.destroy(vdi)
|
|
||||||
session.xenapi.VM.destroy(snapshot)
|
|
||||||
except XenAPI.Failure as error:
|
|
||||||
return("error when destroy snapshot %s"%(error))
|
|
||||||
|
|
||||||
elif status_vm == "Running":
|
|
||||||
self.logger.debug("[%s] Starting in progress",self.backup_name)
|
|
||||||
if dry_run:
|
|
||||||
print("session.xenapi.Async.VM.start(vm,False,True)")
|
|
||||||
else:
|
|
||||||
session.xenapi.Async.VM.start(vm,False,True)
|
|
||||||
|
|
||||||
session.logout()
|
|
||||||
|
|
||||||
if os.path.exists(filename_temp):
|
|
||||||
tar = os.system('tar tf "%s" > /dev/null' % filename_temp)
|
|
||||||
if not tar == 0:
|
|
||||||
os.unlink(filename_temp)
|
|
||||||
return("Tar error")
|
|
||||||
if str2bool(self.verify_export):
|
|
||||||
self.verify_export_xva(filename_temp)
|
|
||||||
os.rename(filename_temp,filename)
|
|
||||||
|
|
||||||
return(0)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def do_backup(self,stats):
|
|
||||||
try:
|
|
||||||
dest_filename = os.path.join(self.backup_dir,"%s-%s.%s" % (self.backup_name,self.backup_start_date,'xva'))
|
|
||||||
|
|
||||||
options = []
|
|
||||||
options_params = " ".join(options)
|
|
||||||
cmd = self.export_xva( vdi_name= self.server_name,filename= dest_filename, halt_vm= self.halt_vm, enable_https=self.enable_https, dry_run= self.dry_run, reuse_snapshot=self.reuse_snapshot)
|
|
||||||
if os.path.exists(dest_filename):
|
|
||||||
stats['written_bytes'] = os.stat(dest_filename)[ST_SIZE]
|
|
||||||
stats['total_files_count'] = 1
|
|
||||||
stats['written_files_count'] = 1
|
|
||||||
stats['total_bytes'] = stats['written_bytes']
|
|
||||||
else:
|
|
||||||
stats['written_bytes'] = 0
|
|
||||||
|
|
||||||
stats['backup_location'] = dest_filename
|
|
||||||
if cmd == 0:
|
|
||||||
stats['log']='XVA backup from %s OK, %d bytes written' % (self.server_name,stats['written_bytes'])
|
|
||||||
stats['status']='OK'
|
|
||||||
else:
|
|
||||||
raise Exception(cmd)
|
|
||||||
|
|
||||||
except BaseException as e:
|
|
||||||
stats['status']='ERROR'
|
|
||||||
stats['log']=str(e)
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
def register_existingbackups(self):
|
|
||||||
"""scan backup dir and insert stats in database"""
|
|
||||||
|
|
||||||
registered = [b['backup_location'] for b in self.dbstat.query('select distinct backup_location from stats where backup_name=?',(self.backup_name,))]
|
|
||||||
|
|
||||||
filelist = os.listdir(self.backup_dir)
|
|
||||||
filelist.sort()
|
|
||||||
for item in filelist:
|
|
||||||
if item.endswith('.xva'):
|
|
||||||
dir_name = os.path.join(self.backup_dir,item)
|
|
||||||
if not dir_name in registered:
|
|
||||||
start = (datetime.datetime.strptime(item,self.backup_name+'-%Y%m%d-%Hh%Mm%S.xva') + datetime.timedelta(0,30*60)).isoformat()
|
|
||||||
if fileisodate(dir_name)>start:
|
|
||||||
stop = fileisodate(dir_name)
|
|
||||||
else:
|
|
||||||
stop = start
|
|
||||||
self.logger.info('Registering %s started on %s',dir_name,start)
|
|
||||||
self.logger.debug(' Disk usage %s','du -sb "%s"' % dir_name)
|
|
||||||
if not self.dry_run:
|
|
||||||
size_bytes = int(os.popen('du -sb "%s"' % dir_name).read().split('\t')[0])
|
|
||||||
else:
|
|
||||||
size_bytes = 0
|
|
||||||
self.logger.debug(' Size in bytes : %i',size_bytes)
|
|
||||||
if not self.dry_run:
|
|
||||||
self.dbstat.add(self.backup_name,self.server_name,'',\
|
|
||||||
backup_start=start,backup_end = stop,status='OK',total_bytes=size_bytes,backup_location=dir_name,TYPE='BACKUP')
|
|
||||||
else:
|
|
||||||
self.logger.info('Skipping %s, already registered',dir_name)
|
|
||||||
|
|
||||||
|
|
||||||
register_driver(backup_xva)
|
|
||||||
|
|
||||||
if __name__=='__main__':
|
|
||||||
logger = logging.getLogger('tisbackup')
|
|
||||||
logger.setLevel(logging.DEBUG)
|
|
||||||
formatter = logging.Formatter('%(asctime)s %(levelname)s %(message)s')
|
|
||||||
handler = logging.StreamHandler()
|
|
||||||
handler.setFormatter(formatter)
|
|
||||||
logger.addHandler(handler)
|
|
||||||
|
|
||||||
cp = ConfigParser()
|
|
||||||
cp.read('/opt/tisbackup/configtest.ini')
|
|
||||||
b = backup_xva()
|
|
||||||
b.read_config(cp)
|
|
||||||
@@ -0,0 +1,526 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# This file is part of TISBackup
|
||||||
|
#
|
||||||
|
# TISBackup is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# TISBackup is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with TISBackup. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
"""Base backup driver class and driver registry."""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
from iniparse import ConfigParser
|
||||||
|
|
||||||
|
from .database import BackupStat
|
||||||
|
from .process import monitor_stdout
|
||||||
|
from .ssh import load_ssh_private_key
|
||||||
|
from .utils import dateof, datetime2isodate, isodate2datetime
|
||||||
|
|
||||||
|
try:
|
||||||
|
import paramiko
|
||||||
|
except ImportError as e:
|
||||||
|
print(("Error : can not load paramiko library %s" % e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Nagios state constants
|
||||||
|
nagiosStateOk = 0
|
||||||
|
nagiosStateWarning = 1
|
||||||
|
nagiosStateCritical = 2
|
||||||
|
nagiosStateUnknown = 3
|
||||||
|
|
||||||
|
# Global driver registry
|
||||||
|
backup_drivers = {}
|
||||||
|
|
||||||
|
|
||||||
|
def register_driver(driverclass):
|
||||||
|
"""Register a backup driver class in the global registry."""
|
||||||
|
backup_drivers[driverclass.type] = driverclass
|
||||||
|
|
||||||
|
|
||||||
|
class backup_generic(ABC):
|
||||||
|
"""Generic ancestor class for backups, not registered"""
|
||||||
|
|
||||||
|
type = "generic"
|
||||||
|
required_params = ["type", "backup_name", "backup_dir", "server_name", "backup_retention_time", "maximum_backup_age"]
|
||||||
|
optional_params = ["preexec", "postexec", "description", "private_key", "remote_user", "ssh_port"]
|
||||||
|
|
||||||
|
logger = logging.getLogger("tisbackup")
|
||||||
|
backup_name = ""
|
||||||
|
backup_dir = ""
|
||||||
|
server_name = ""
|
||||||
|
remote_user = "root"
|
||||||
|
description = ""
|
||||||
|
dbstat = None
|
||||||
|
dry_run = False
|
||||||
|
preexec = ""
|
||||||
|
postexec = ""
|
||||||
|
maximum_backup_age = None
|
||||||
|
backup_retention_time = None
|
||||||
|
verbose = False
|
||||||
|
private_key = ""
|
||||||
|
ssh_port = 22
|
||||||
|
|
||||||
|
def __init__(self, backup_name, backup_dir, dbstat=None, dry_run=False):
|
||||||
|
if not re.match(r"^[A-Za-z0-9_\-\.]*$", backup_name):
|
||||||
|
raise Exception("The backup name %s should contain only alphanumerical characters" % backup_name)
|
||||||
|
self.backup_name = backup_name
|
||||||
|
self.backup_dir = backup_dir
|
||||||
|
|
||||||
|
self.dbstat = dbstat
|
||||||
|
assert isinstance(self.dbstat, BackupStat) or self.dbstat is None
|
||||||
|
|
||||||
|
if not os.path.isdir(self.backup_dir):
|
||||||
|
os.makedirs(self.backup_dir)
|
||||||
|
|
||||||
|
self.dry_run = dry_run
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_help(cls):
|
||||||
|
return """\
|
||||||
|
%(type)s : %(desc)s
|
||||||
|
Required params : %(required)s
|
||||||
|
Optional params : %(optional)s
|
||||||
|
""" % {"type": cls.type, "desc": cls.__doc__, "required": ",".join(cls.required_params), "optional": ",".join(cls.optional_params)}
|
||||||
|
|
||||||
|
def check_required_params(self):
|
||||||
|
for name in self.required_params:
|
||||||
|
if not hasattr(self, name) or not getattr(self, name):
|
||||||
|
raise Exception("[%s] Config Attribute %s is required" % (self.backup_name, name))
|
||||||
|
if (self.preexec or self.postexec) and (not self.private_key or not self.remote_user):
|
||||||
|
raise Exception("[%s] remote_user and private_key file required if preexec or postexec is used" % self.backup_name)
|
||||||
|
|
||||||
|
def read_config(self, iniconf):
|
||||||
|
assert isinstance(iniconf, ConfigParser)
|
||||||
|
allowed_params = self.required_params + self.optional_params
|
||||||
|
for name, value in iniconf.items(self.backup_name):
|
||||||
|
if name not in allowed_params:
|
||||||
|
self.logger.critical('[%s] Invalid param name "%s"', self.backup_name, name)
|
||||||
|
raise Exception('[%s] Invalid param name "%s"', self.backup_name, name)
|
||||||
|
self.logger.debug("[%s] reading param %s = %s ", self.backup_name, name, value)
|
||||||
|
setattr(self, name, value)
|
||||||
|
|
||||||
|
# if retention (in days) is not defined at section level, get default global one.
|
||||||
|
if not self.backup_retention_time:
|
||||||
|
self.backup_retention_time = iniconf.getint("global", "backup_retention_time")
|
||||||
|
|
||||||
|
# for nagios, if maximum last backup age (in hours) is not defined at section level, get default global one.
|
||||||
|
if not self.maximum_backup_age:
|
||||||
|
self.maximum_backup_age = iniconf.getint("global", "maximum_backup_age")
|
||||||
|
|
||||||
|
self.ssh_port = int(self.ssh_port)
|
||||||
|
self.backup_retention_time = int(self.backup_retention_time)
|
||||||
|
self.maximum_backup_age = int(self.maximum_backup_age)
|
||||||
|
|
||||||
|
self.check_required_params()
|
||||||
|
|
||||||
|
def do_preexec(self, stats):
|
||||||
|
self.logger.info("[%s] executing preexec %s ", self.backup_name, self.preexec)
|
||||||
|
mykey = load_ssh_private_key(self.private_key)
|
||||||
|
|
||||||
|
ssh = paramiko.SSHClient()
|
||||||
|
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
ssh.connect(self.server_name, username=self.remote_user, pkey=mykey, port=self.ssh_port)
|
||||||
|
tran = ssh.get_transport()
|
||||||
|
chan = tran.open_session()
|
||||||
|
|
||||||
|
# chan.set_combine_stderr(True)
|
||||||
|
chan.get_pty()
|
||||||
|
stdout = chan.makefile()
|
||||||
|
|
||||||
|
if not self.dry_run:
|
||||||
|
chan.exec_command(self.preexec)
|
||||||
|
output = stdout.read()
|
||||||
|
exit_code = chan.recv_exit_status()
|
||||||
|
self.logger.info('[%s] preexec exit code : "%i", output : %s', self.backup_name, exit_code, output)
|
||||||
|
return exit_code
|
||||||
|
else:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def do_postexec(self, stats):
|
||||||
|
self.logger.info("[%s] executing postexec %s ", self.backup_name, self.postexec)
|
||||||
|
mykey = load_ssh_private_key(self.private_key)
|
||||||
|
|
||||||
|
ssh = paramiko.SSHClient()
|
||||||
|
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
ssh.connect(self.server_name, username=self.remote_user, pkey=mykey, port=self.ssh_port)
|
||||||
|
tran = ssh.get_transport()
|
||||||
|
chan = tran.open_session()
|
||||||
|
|
||||||
|
# chan.set_combine_stderr(True)
|
||||||
|
chan.get_pty()
|
||||||
|
stdout = chan.makefile()
|
||||||
|
|
||||||
|
if not self.dry_run:
|
||||||
|
chan.exec_command(self.postexec)
|
||||||
|
output = stdout.read()
|
||||||
|
exit_code = chan.recv_exit_status()
|
||||||
|
self.logger.info('[%s] postexec exit code : "%i", output : %s', self.backup_name, exit_code, output)
|
||||||
|
return exit_code
|
||||||
|
else:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def do_backup(self, stats):
|
||||||
|
"""stats dict with keys : total_files_count,written_files_count,total_bytes,written_bytes"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def check_params_connections(self):
|
||||||
|
"""Perform a dry run trying to connect without actually doing backup"""
|
||||||
|
self.check_required_params()
|
||||||
|
|
||||||
|
def process_backup(self):
|
||||||
|
"""Process the backup.
|
||||||
|
launch
|
||||||
|
- do_preexec
|
||||||
|
- do_backup
|
||||||
|
- do_postexec
|
||||||
|
|
||||||
|
returns a dict for stats
|
||||||
|
"""
|
||||||
|
self.logger.info("[%s] ######### Starting backup", self.backup_name)
|
||||||
|
|
||||||
|
starttime = time.time()
|
||||||
|
self.backup_start_date = datetime.datetime.now().strftime("%Y%m%d-%Hh%Mm%S")
|
||||||
|
|
||||||
|
if not self.dry_run and self.dbstat:
|
||||||
|
stat_rowid = self.dbstat.start(backup_name=self.backup_name, server_name=self.server_name, TYPE="BACKUP")
|
||||||
|
else:
|
||||||
|
stat_rowid = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
stats = {}
|
||||||
|
stats["total_files_count"] = 0
|
||||||
|
stats["written_files_count"] = 0
|
||||||
|
stats["total_bytes"] = 0
|
||||||
|
stats["written_bytes"] = 0
|
||||||
|
stats["log"] = ""
|
||||||
|
stats["status"] = "Running"
|
||||||
|
stats["backup_location"] = None
|
||||||
|
|
||||||
|
if self.preexec.strip():
|
||||||
|
exit_code = self.do_preexec(stats)
|
||||||
|
if exit_code != 0:
|
||||||
|
raise Exception('Preexec "%s" failed with exit code "%i"' % (self.preexec, exit_code))
|
||||||
|
|
||||||
|
self.do_backup(stats)
|
||||||
|
|
||||||
|
if self.postexec.strip():
|
||||||
|
exit_code = self.do_postexec(stats)
|
||||||
|
if exit_code != 0:
|
||||||
|
raise Exception('Postexec "%s" failed with exit code "%i"' % (self.postexec, exit_code))
|
||||||
|
|
||||||
|
endtime = time.time()
|
||||||
|
duration = (endtime - starttime) / 3600.0
|
||||||
|
if not self.dry_run and self.dbstat:
|
||||||
|
self.dbstat.finish(
|
||||||
|
stat_rowid,
|
||||||
|
backup_end=datetime2isodate(datetime.datetime.now()),
|
||||||
|
backup_duration=duration,
|
||||||
|
total_files_count=stats["total_files_count"],
|
||||||
|
written_files_count=stats["written_files_count"],
|
||||||
|
total_bytes=stats["total_bytes"],
|
||||||
|
written_bytes=stats["written_bytes"],
|
||||||
|
status=stats["status"],
|
||||||
|
log=stats["log"],
|
||||||
|
backup_location=stats["backup_location"],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.logger.info("[%s] ######### Backup finished : %s", self.backup_name, stats["log"])
|
||||||
|
return stats
|
||||||
|
|
||||||
|
except BaseException as e:
|
||||||
|
stats["status"] = "ERROR"
|
||||||
|
stats["log"] = str(e)
|
||||||
|
endtime = time.time()
|
||||||
|
duration = (endtime - starttime) / 3600.0
|
||||||
|
if not self.dry_run and self.dbstat:
|
||||||
|
self.dbstat.finish(
|
||||||
|
stat_rowid,
|
||||||
|
backup_end=datetime2isodate(datetime.datetime.now()),
|
||||||
|
backup_duration=duration,
|
||||||
|
total_files_count=stats["total_files_count"],
|
||||||
|
written_files_count=stats["written_files_count"],
|
||||||
|
total_bytes=stats["total_bytes"],
|
||||||
|
written_bytes=stats["written_bytes"],
|
||||||
|
status=stats["status"],
|
||||||
|
log=stats["log"],
|
||||||
|
backup_location=stats["backup_location"],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.logger.error("[%s] ######### Backup finished with ERROR: %s", self.backup_name, stats["log"])
|
||||||
|
raise
|
||||||
|
|
||||||
|
def checknagios(self):
|
||||||
|
"""
|
||||||
|
Returns a tuple (nagiosstatus,message) for the current backup_name
|
||||||
|
Read status from dbstat database
|
||||||
|
"""
|
||||||
|
if not self.dbstat:
|
||||||
|
self.logger.warn("[%s] checknagios : no database provided", self.backup_name)
|
||||||
|
return ("No database provided", nagiosStateUnknown)
|
||||||
|
else:
|
||||||
|
self.logger.debug(
|
||||||
|
'[%s] checknagios : sql query "%s" %s',
|
||||||
|
self.backup_name,
|
||||||
|
"select status, backup_end, log from stats where TYPE='BACKUP' AND backup_name=? order by backup_end desc limit 30",
|
||||||
|
self.backup_name,
|
||||||
|
)
|
||||||
|
q = self.dbstat.query(
|
||||||
|
"select status, backup_start, backup_end, log, backup_location, total_bytes from stats where TYPE='BACKUP' AND backup_name=? order by backup_start desc limit 30",
|
||||||
|
(self.backup_name,),
|
||||||
|
)
|
||||||
|
if not q:
|
||||||
|
self.logger.debug("[%s] checknagios : no result from query", self.backup_name)
|
||||||
|
return (nagiosStateCritical, "CRITICAL : No backup found for %s in database" % self.backup_name)
|
||||||
|
else:
|
||||||
|
mindate = datetime2isodate((datetime.datetime.now() - datetime.timedelta(hours=self.maximum_backup_age)))
|
||||||
|
self.logger.debug("[%s] checknagios : looking for most recent OK not older than %s", self.backup_name, mindate)
|
||||||
|
for b in q:
|
||||||
|
if b["backup_end"] >= mindate and b["status"] == "OK":
|
||||||
|
# check if backup actually exists on registered backup location and is newer than backup start date
|
||||||
|
if b["total_bytes"] == 0:
|
||||||
|
return (nagiosStateWarning, "WARNING : No data to backup was found for %s" % (self.backup_name,))
|
||||||
|
|
||||||
|
if not b["backup_location"]:
|
||||||
|
return (
|
||||||
|
nagiosStateWarning,
|
||||||
|
"WARNING : No Backup location found for %s finished on (%s) %s"
|
||||||
|
% (self.backup_name, isodate2datetime(b["backup_end"]), b["log"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
if os.path.isfile(b["backup_location"]):
|
||||||
|
backup_actual_date = datetime.datetime.fromtimestamp(os.stat(b["backup_location"]).st_ctime)
|
||||||
|
if backup_actual_date + datetime.timedelta(hours=1) > isodate2datetime(b["backup_start"]):
|
||||||
|
return (
|
||||||
|
nagiosStateOk,
|
||||||
|
"OK Backup %s (%s), %s" % (self.backup_name, isodate2datetime(b["backup_end"]), b["log"]),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return (
|
||||||
|
nagiosStateCritical,
|
||||||
|
"CRITICAL Backup %s (%s), %s seems older than start of backup"
|
||||||
|
% (self.backup_name, isodate2datetime(b["backup_end"]), b["log"]),
|
||||||
|
)
|
||||||
|
elif os.path.isdir(b["backup_location"]):
|
||||||
|
return (
|
||||||
|
nagiosStateOk,
|
||||||
|
"OK Backup %s (%s), %s" % (self.backup_name, isodate2datetime(b["backup_end"]), b["log"]),
|
||||||
|
)
|
||||||
|
elif self.type == "copy-vm-xcp":
|
||||||
|
return (
|
||||||
|
nagiosStateOk,
|
||||||
|
"OK Backup %s (%s), %s" % (self.backup_name, isodate2datetime(b["backup_end"]), b["log"]),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return (
|
||||||
|
nagiosStateCritical,
|
||||||
|
"CRITICAL Backup %s (%s), %s has disapeared from backup location %s"
|
||||||
|
% (self.backup_name, isodate2datetime(b["backup_end"]), b["log"], b["backup_location"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.logger.debug(
|
||||||
|
"[%s] checknagios : looking for most recent Warning or Running not older than %s", self.backup_name, mindate
|
||||||
|
)
|
||||||
|
for b in q:
|
||||||
|
if b["backup_end"] >= mindate and b["status"] in ("Warning", "Running"):
|
||||||
|
return (nagiosStateWarning, "WARNING : Backup %s still running or warning. %s" % (self.backup_name, b["log"]))
|
||||||
|
|
||||||
|
self.logger.debug("[%s] checknagios : No Ok or warning recent backup found", self.backup_name)
|
||||||
|
return (nagiosStateCritical, "CRITICAL : No recent backup for %s" % self.backup_name)
|
||||||
|
|
||||||
|
def cleanup_backup(self):
|
||||||
|
"""Removes obsolete backups (older than backup_retention_time)"""
|
||||||
|
mindate = datetime2isodate((dateof(datetime.datetime.now()) - datetime.timedelta(days=self.backup_retention_time)))
|
||||||
|
# check if there is at least 1 "OK" backup left after cleanup :
|
||||||
|
ok_backups = self.dbstat.query(
|
||||||
|
'select backup_location from stats where TYPE="BACKUP" and backup_name=? and backup_start>=? and status="OK" order by backup_start desc',
|
||||||
|
(self.backup_name, mindate),
|
||||||
|
)
|
||||||
|
removed = []
|
||||||
|
if ok_backups and os.path.exists(ok_backups[0]["backup_location"]):
|
||||||
|
records = self.dbstat.query(
|
||||||
|
'select status, backup_start, backup_end, log, backup_location from stats where backup_name=? and backup_start<? and backup_location is not null and TYPE="BACKUP" order by backup_start',
|
||||||
|
(self.backup_name, mindate),
|
||||||
|
)
|
||||||
|
if records:
|
||||||
|
for oldbackup_location in [rec["backup_location"] for rec in records if rec["backup_location"]]:
|
||||||
|
try:
|
||||||
|
if os.path.isdir(oldbackup_location) and self.backup_dir in oldbackup_location:
|
||||||
|
self.logger.info('[%s] removing directory "%s"', self.backup_name, oldbackup_location)
|
||||||
|
if not self.dry_run:
|
||||||
|
if self.type == "rsync+btrfs+ssh" or self.type == "rsync+btrfs":
|
||||||
|
cmd = "/bin/btrfs subvolume delete %s" % oldbackup_location
|
||||||
|
process = subprocess.Popen(
|
||||||
|
cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, close_fds=True
|
||||||
|
)
|
||||||
|
log = monitor_stdout(process, "", self)
|
||||||
|
returncode = process.returncode
|
||||||
|
if returncode != 0:
|
||||||
|
self.logger.error("[" + self.backup_name + "] shell program exited with error code: %s" % log)
|
||||||
|
raise Exception(
|
||||||
|
"[" + self.backup_name + "] shell program exited with error code " + str(returncode), cmd
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.logger.info(
|
||||||
|
"[" + self.backup_name + "] deleting snapshot volume: %s" % oldbackup_location.encode("ascii")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
shutil.rmtree(oldbackup_location.encode("ascii"))
|
||||||
|
if os.path.isfile(oldbackup_location) and self.backup_dir in oldbackup_location:
|
||||||
|
self.logger.debug('[%s] removing file "%s"', self.backup_name, oldbackup_location)
|
||||||
|
if not self.dry_run:
|
||||||
|
os.remove(oldbackup_location)
|
||||||
|
self.logger.debug('Cleanup_backup : Removing records from DB : [%s]-"%s"', self.backup_name, oldbackup_location)
|
||||||
|
if not self.dry_run:
|
||||||
|
self.dbstat.db.execute(
|
||||||
|
'update stats set TYPE="CLEAN" where backup_name=? and backup_location=?',
|
||||||
|
(self.backup_name, oldbackup_location),
|
||||||
|
)
|
||||||
|
self.dbstat.db.commit()
|
||||||
|
except BaseException as e:
|
||||||
|
self.logger.error('cleanup_backup : Unable to remove directory/file "%s". Error %s', oldbackup_location, e)
|
||||||
|
removed.append((self.backup_name, oldbackup_location))
|
||||||
|
else:
|
||||||
|
self.logger.debug("[%s] cleanup : no result for query", self.backup_name)
|
||||||
|
else:
|
||||||
|
self.logger.info("Nothing to do because we want to keep at least one OK backup after cleaning")
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
"[%s] Cleanup finished : removed : %s", self.backup_name, ",".join([('[%s]-"%s"') % r for r in removed]) or "Nothing"
|
||||||
|
)
|
||||||
|
return removed
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def register_existingbackups(self):
|
||||||
|
pass
|
||||||
|
# """scan existing backups and insert stats in database"""
|
||||||
|
# registered = [b['backup_location'] for b in self.dbstat.query('select distinct backup_location from stats where backup_name=?',[self.backup_name])]
|
||||||
|
# raise Exception('Abstract method')
|
||||||
|
|
||||||
|
def export_latestbackup(self, destdir):
|
||||||
|
"""Copy (rsync) latest OK backup to external storage located at locally mounted "destdir" """
|
||||||
|
stats = {}
|
||||||
|
stats["total_files_count"] = 0
|
||||||
|
stats["written_files_count"] = 0
|
||||||
|
stats["total_bytes"] = 0
|
||||||
|
stats["written_bytes"] = 0
|
||||||
|
stats["log"] = ""
|
||||||
|
stats["status"] = "Running"
|
||||||
|
if not self.dbstat:
|
||||||
|
self.logger.critical("[%s] export_latestbackup : no database provided", self.backup_name)
|
||||||
|
raise Exception("No database")
|
||||||
|
else:
|
||||||
|
latest_sql = """\
|
||||||
|
select status, backup_start, backup_end, log, backup_location, total_bytes
|
||||||
|
from stats
|
||||||
|
where backup_name=? and status='OK' and TYPE='BACKUP'
|
||||||
|
order by backup_start desc limit 30"""
|
||||||
|
self.logger.debug('[%s] export_latestbackup : sql query "%s" %s', self.backup_name, latest_sql, self.backup_name)
|
||||||
|
q = self.dbstat.query(latest_sql, (self.backup_name,))
|
||||||
|
if not q:
|
||||||
|
self.logger.debug("[%s] export_latestbackup : no result from query", self.backup_name)
|
||||||
|
raise Exception("No OK backup found for %s in database" % self.backup_name)
|
||||||
|
else:
|
||||||
|
latest = q[0]
|
||||||
|
backup_source = latest["backup_location"]
|
||||||
|
backup_dest = os.path.join(os.path.abspath(destdir), self.backup_name)
|
||||||
|
if not os.path.exists(backup_source):
|
||||||
|
raise Exception("Backup source %s doesn't exists" % backup_source)
|
||||||
|
|
||||||
|
# ensure there is a slash at end
|
||||||
|
if os.path.isdir(backup_source) and backup_source[-1] != "/":
|
||||||
|
backup_source += "/"
|
||||||
|
if backup_dest[-1] != "/":
|
||||||
|
backup_dest += "/"
|
||||||
|
|
||||||
|
if not os.path.isdir(backup_dest):
|
||||||
|
os.makedirs(backup_dest)
|
||||||
|
|
||||||
|
options = ["-aP", "--stats", "--delete-excluded", "--numeric-ids", "--delete-after"]
|
||||||
|
if self.logger.level:
|
||||||
|
options.append("-P")
|
||||||
|
|
||||||
|
if self.dry_run:
|
||||||
|
options.append("-d")
|
||||||
|
|
||||||
|
options_params = " ".join(options)
|
||||||
|
|
||||||
|
cmd = "/usr/bin/rsync %s %s %s 2>&1" % (options_params, backup_source, backup_dest)
|
||||||
|
self.logger.debug("[%s] rsync : %s", self.backup_name, cmd)
|
||||||
|
|
||||||
|
if not self.dry_run:
|
||||||
|
self.line = ""
|
||||||
|
starttime = time.time()
|
||||||
|
stat_rowid = self.dbstat.start(backup_name=self.backup_name, server_name=self.server_name, TYPE="EXPORT")
|
||||||
|
|
||||||
|
process = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, close_fds=True)
|
||||||
|
|
||||||
|
def ondata(data, context):
|
||||||
|
if context.verbose:
|
||||||
|
print(data)
|
||||||
|
context.logger.debug(data)
|
||||||
|
|
||||||
|
log = monitor_stdout(process, ondata, self)
|
||||||
|
|
||||||
|
for l in log.splitlines():
|
||||||
|
if l.startswith("Number of files:"):
|
||||||
|
stats["total_files_count"] += int(re.findall("[0-9]+", l.split(":")[1])[0])
|
||||||
|
if l.startswith("Number of files transferred:"):
|
||||||
|
stats["written_files_count"] += int(l.split(":")[1])
|
||||||
|
if l.startswith("Total file size:"):
|
||||||
|
stats["total_bytes"] += float(l.replace(",", "").split(":")[1].split()[0])
|
||||||
|
if l.startswith("Total transferred file size:"):
|
||||||
|
stats["written_bytes"] += float(l.replace(",", "").split(":")[1].split()[0])
|
||||||
|
returncode = process.returncode
|
||||||
|
## deal with exit code 24 (file vanished)
|
||||||
|
if returncode == 24:
|
||||||
|
self.logger.warning("[" + self.backup_name + "] Note: some files vanished before transfer")
|
||||||
|
elif returncode == 23:
|
||||||
|
self.logger.warning("[" + self.backup_name + "] unable so set uid on some files")
|
||||||
|
elif returncode != 0:
|
||||||
|
self.logger.error("[" + self.backup_name + "] shell program exited with error code ")
|
||||||
|
raise Exception("[" + self.backup_name + "] shell program exited with error code " + str(returncode), cmd)
|
||||||
|
else:
|
||||||
|
print(cmd)
|
||||||
|
|
||||||
|
stats["status"] = "OK"
|
||||||
|
self.logger.info(
|
||||||
|
"export backup from %s to %s OK, %d bytes written for %d changed files"
|
||||||
|
% (backup_source, backup_dest, stats["written_bytes"], stats["written_files_count"])
|
||||||
|
)
|
||||||
|
|
||||||
|
endtime = time.time()
|
||||||
|
duration = (endtime - starttime) / 3600.0
|
||||||
|
if not self.dry_run and self.dbstat:
|
||||||
|
self.dbstat.finish(
|
||||||
|
stat_rowid,
|
||||||
|
backup_end=datetime2isodate(datetime.datetime.now()),
|
||||||
|
backup_duration=duration,
|
||||||
|
total_files_count=stats["total_files_count"],
|
||||||
|
written_files_count=stats["written_files_count"],
|
||||||
|
total_bytes=stats["total_bytes"],
|
||||||
|
written_bytes=stats["written_bytes"],
|
||||||
|
status=stats["status"],
|
||||||
|
log=stats["log"],
|
||||||
|
backup_location=backup_dest,
|
||||||
|
)
|
||||||
|
return stats
|
||||||
@@ -1,937 +0,0 @@
|
|||||||
#!/usr/bin/python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# This file is part of TISBackup
|
|
||||||
#
|
|
||||||
# TISBackup is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# TISBackup is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with TISBackup. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
#
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import re
|
|
||||||
import logging
|
|
||||||
import datetime
|
|
||||||
import time
|
|
||||||
from iniparse import ConfigParser
|
|
||||||
import sqlite3
|
|
||||||
import shutil
|
|
||||||
import select
|
|
||||||
|
|
||||||
import sys
|
|
||||||
|
|
||||||
try:
|
|
||||||
sys.stderr = open('/dev/null') # Silence silly warnings from paramiko
|
|
||||||
import paramiko
|
|
||||||
except ImportError as e:
|
|
||||||
print(("Error : can not load paramiko library %s" % e))
|
|
||||||
raise
|
|
||||||
|
|
||||||
sys.stderr = sys.__stderr__
|
|
||||||
|
|
||||||
nagiosStateOk = 0
|
|
||||||
nagiosStateWarning = 1
|
|
||||||
nagiosStateCritical = 2
|
|
||||||
nagiosStateUnknown = 3
|
|
||||||
|
|
||||||
backup_drivers = {}
|
|
||||||
def register_driver(driverclass):
|
|
||||||
backup_drivers[driverclass.type] = driverclass
|
|
||||||
|
|
||||||
def datetime2isodate(adatetime=None):
|
|
||||||
if not adatetime:
|
|
||||||
adatetime = datetime.datetime.now()
|
|
||||||
assert(isinstance(adatetime,datetime.datetime))
|
|
||||||
return adatetime.isoformat()
|
|
||||||
|
|
||||||
def isodate2datetime(isodatestr):
|
|
||||||
# we remove the microseconds part as it is not working for python2.5 strptime
|
|
||||||
return datetime.datetime.strptime(isodatestr.split('.')[0] , "%Y-%m-%dT%H:%M:%S")
|
|
||||||
|
|
||||||
def time2display(adatetime):
|
|
||||||
return adatetime.strftime("%Y-%m-%d %H:%M")
|
|
||||||
|
|
||||||
def hours_minutes(hours):
|
|
||||||
if hours is None:
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
return "%02i:%02i" % ( int(hours) , int((hours - int(hours)) * 60.0))
|
|
||||||
|
|
||||||
def fileisodate(filename):
|
|
||||||
return datetime.datetime.fromtimestamp(os.stat(filename).st_mtime).isoformat()
|
|
||||||
|
|
||||||
def dateof(adatetime):
|
|
||||||
return adatetime.replace(hour=0,minute=0,second=0,microsecond=0)
|
|
||||||
|
|
||||||
#####################################
|
|
||||||
# http://code.activestate.com/recipes/498181-add-thousands-separator-commas-to-formatted-number/
|
|
||||||
# Code from Michael Robellard's comment made 28 Feb 2010
|
|
||||||
# Modified for leading +, -, space on 1 Mar 2010 by Glenn Linderman
|
|
||||||
#
|
|
||||||
# Tail recursion removed and leading garbage handled on March 12 2010, Alessandro Forghieri
|
|
||||||
def splitThousands( s, tSep=',', dSep='.'):
|
|
||||||
'''Splits a general float on thousands. GIGO on general input'''
|
|
||||||
if s == None:
|
|
||||||
return 0
|
|
||||||
if not isinstance( s, str ):
|
|
||||||
s = str( s )
|
|
||||||
|
|
||||||
cnt=0
|
|
||||||
numChars=dSep+'0123456789'
|
|
||||||
ls=len(s)
|
|
||||||
while cnt < ls and s[cnt] not in numChars: cnt += 1
|
|
||||||
|
|
||||||
lhs = s[ 0:cnt ]
|
|
||||||
s = s[ cnt: ]
|
|
||||||
if dSep == '':
|
|
||||||
cnt = -1
|
|
||||||
else:
|
|
||||||
cnt = s.rfind( dSep )
|
|
||||||
if cnt > 0:
|
|
||||||
rhs = dSep + s[ cnt+1: ]
|
|
||||||
s = s[ :cnt ]
|
|
||||||
else:
|
|
||||||
rhs = ''
|
|
||||||
|
|
||||||
splt=''
|
|
||||||
while s != '':
|
|
||||||
splt= s[ -3: ] + tSep + splt
|
|
||||||
s = s[ :-3 ]
|
|
||||||
|
|
||||||
return lhs + splt[ :-1 ] + rhs
|
|
||||||
|
|
||||||
|
|
||||||
def call_external_process(shell_string):
|
|
||||||
p = subprocess.call(shell_string, shell=True)
|
|
||||||
if (p != 0 ):
|
|
||||||
raise Exception('shell program exited with error code ' + str(p), shell_string)
|
|
||||||
|
|
||||||
def check_string(test_string):
|
|
||||||
pattern = r'[^\.A-Za-z0-9\-_]'
|
|
||||||
if re.search(pattern, test_string):
|
|
||||||
#Character other then . a-z 0-9 was found
|
|
||||||
print(('Invalid : %r' % (test_string,)))
|
|
||||||
|
|
||||||
def convert_bytes(bytes):
|
|
||||||
if bytes is None:
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
bytes = float(bytes)
|
|
||||||
if bytes >= 1099511627776:
|
|
||||||
terabytes = bytes / 1099511627776
|
|
||||||
size = '%.2fT' % terabytes
|
|
||||||
elif bytes >= 1073741824:
|
|
||||||
gigabytes = bytes / 1073741824
|
|
||||||
size = '%.2fG' % gigabytes
|
|
||||||
elif bytes >= 1048576:
|
|
||||||
megabytes = bytes / 1048576
|
|
||||||
size = '%.2fM' % megabytes
|
|
||||||
elif bytes >= 1024:
|
|
||||||
kilobytes = bytes / 1024
|
|
||||||
size = '%.2fK' % kilobytes
|
|
||||||
else:
|
|
||||||
size = '%.2fb' % bytes
|
|
||||||
return size
|
|
||||||
|
|
||||||
## {{{ http://code.activestate.com/recipes/81189/ (r2)
|
|
||||||
def pp(cursor, data=None, rowlens=0, callback=None):
|
|
||||||
"""
|
|
||||||
pretty print a query result as a table
|
|
||||||
callback is a function called for each field (fieldname,value) to format the output
|
|
||||||
"""
|
|
||||||
def defaultcb(fieldname,value):
|
|
||||||
return value
|
|
||||||
|
|
||||||
if not callback:
|
|
||||||
callback = defaultcb
|
|
||||||
|
|
||||||
d = cursor.description
|
|
||||||
if not d:
|
|
||||||
return "#### NO RESULTS ###"
|
|
||||||
names = []
|
|
||||||
lengths = []
|
|
||||||
rules = []
|
|
||||||
if not data:
|
|
||||||
data = cursor.fetchall()
|
|
||||||
for dd in d: # iterate over description
|
|
||||||
l = dd[1]
|
|
||||||
if not l:
|
|
||||||
l = 12 # or default arg ...
|
|
||||||
l = max(l, len(dd[0])) # handle long names
|
|
||||||
names.append(dd[0])
|
|
||||||
lengths.append(l)
|
|
||||||
for col in range(len(lengths)):
|
|
||||||
if rowlens:
|
|
||||||
rls = [len(str(callback(d[col][0],row[col]))) for row in data if row[col]]
|
|
||||||
lengths[col] = max([lengths[col]]+rls)
|
|
||||||
rules.append("-"*lengths[col])
|
|
||||||
format = " ".join(["%%-%ss" % l for l in lengths])
|
|
||||||
result = [format % tuple(names)]
|
|
||||||
result.append(format % tuple(rules))
|
|
||||||
for row in data:
|
|
||||||
row_cb=[]
|
|
||||||
for col in range(len(d)):
|
|
||||||
row_cb.append(callback(d[col][0],row[col]))
|
|
||||||
result.append(format % tuple(row_cb))
|
|
||||||
return "\n".join(result)
|
|
||||||
## end of http://code.activestate.com/recipes/81189/ }}}
|
|
||||||
|
|
||||||
|
|
||||||
def html_table(cur,callback=None):
|
|
||||||
"""
|
|
||||||
cur est un cursor issu d'une requete
|
|
||||||
callback est une fonction qui prend (rowmap,fieldname,value)
|
|
||||||
et renvoie une representation texte
|
|
||||||
"""
|
|
||||||
def safe_unicode(iso):
|
|
||||||
if iso is None:
|
|
||||||
return None
|
|
||||||
elif isinstance(iso, str):
|
|
||||||
return iso #.decode()
|
|
||||||
else:
|
|
||||||
return iso
|
|
||||||
|
|
||||||
def itermap(cur):
|
|
||||||
for row in cur:
|
|
||||||
yield dict((cur.description[idx][0], value)
|
|
||||||
for idx, value in enumerate(row))
|
|
||||||
|
|
||||||
head="<tr>"+"".join(["<th>"+c[0]+"</th>" for c in cur.description])+"</tr>"
|
|
||||||
lines=""
|
|
||||||
if callback:
|
|
||||||
for r in itermap(cur):
|
|
||||||
lines=lines+"<tr>"+"".join(["<td>"+str(callback(r,c[0],safe_unicode(r[c[0]])))+"</td>" for c in cur.description])+"</tr>"
|
|
||||||
else:
|
|
||||||
for r in cur:
|
|
||||||
lines=lines+"<tr>"+"".join(["<td>"+safe_unicode(c)+"</td>" for c in r])+"</tr>"
|
|
||||||
|
|
||||||
return "<table border=1 cellpadding=2 cellspacing=0>%s%s</table>" % (head,lines)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def monitor_stdout(aprocess, onoutputdata,context):
|
|
||||||
"""Reads data from stdout and stderr from aprocess and return as a string
|
|
||||||
on each chunk, call a call back onoutputdata(dataread)
|
|
||||||
"""
|
|
||||||
assert(isinstance(aprocess,subprocess.Popen))
|
|
||||||
read_set = []
|
|
||||||
stdout = []
|
|
||||||
line = ''
|
|
||||||
|
|
||||||
if aprocess.stdout:
|
|
||||||
read_set.append(aprocess.stdout)
|
|
||||||
if aprocess.stderr:
|
|
||||||
read_set.append(aprocess.stderr)
|
|
||||||
|
|
||||||
while read_set:
|
|
||||||
try:
|
|
||||||
rlist, wlist, xlist = select.select(read_set, [], [])
|
|
||||||
except select.error as e:
|
|
||||||
if e.args[0] == errno.EINTR:
|
|
||||||
continue
|
|
||||||
raise
|
|
||||||
|
|
||||||
# Reads one line from stdout
|
|
||||||
if aprocess.stdout in rlist:
|
|
||||||
data = os.read(aprocess.stdout.fileno(), 1)
|
|
||||||
data = data.decode(errors='ignore')
|
|
||||||
if data == "":
|
|
||||||
aprocess.stdout.close()
|
|
||||||
read_set.remove(aprocess.stdout)
|
|
||||||
while data and not data in ('\n','\r'):
|
|
||||||
line += data
|
|
||||||
data = os.read(aprocess.stdout.fileno(), 1)
|
|
||||||
data = data.decode(errors='ignore')
|
|
||||||
if line or data in ('\n','\r'):
|
|
||||||
stdout.append(line)
|
|
||||||
if onoutputdata:
|
|
||||||
onoutputdata(line,context)
|
|
||||||
line=''
|
|
||||||
|
|
||||||
# Reads one line from stderr
|
|
||||||
if aprocess.stderr in rlist:
|
|
||||||
data = os.read(aprocess.stderr.fileno(), 1)
|
|
||||||
data = data.decode(errors='ignore')
|
|
||||||
if data == "":
|
|
||||||
aprocess.stderr.close()
|
|
||||||
read_set.remove(aprocess.stderr)
|
|
||||||
while data and not data in ('\n','\r'):
|
|
||||||
line += data
|
|
||||||
data = os.read(aprocess.stderr.fileno(), 1)
|
|
||||||
data = data.decode(errors='ignore')
|
|
||||||
if line or data in ('\n','\r'):
|
|
||||||
stdout.append(line)
|
|
||||||
if onoutputdata:
|
|
||||||
onoutputdata(line,context)
|
|
||||||
line=''
|
|
||||||
|
|
||||||
aprocess.wait()
|
|
||||||
if line:
|
|
||||||
stdout.append(line)
|
|
||||||
if onoutputdata:
|
|
||||||
onoutputdata(line,context)
|
|
||||||
return "\n".join(stdout)
|
|
||||||
|
|
||||||
|
|
||||||
def str2bool(val):
|
|
||||||
if type(val) != bool:
|
|
||||||
return val.lower() in ("yes", "true", "t", "1")
|
|
||||||
|
|
||||||
|
|
||||||
class BackupStat:
|
|
||||||
dbpath = ''
|
|
||||||
db = None
|
|
||||||
logger = logging.getLogger('tisbackup')
|
|
||||||
|
|
||||||
def __init__(self,dbpath):
|
|
||||||
self.dbpath = dbpath
|
|
||||||
if not os.path.isfile(self.dbpath):
|
|
||||||
self.db=sqlite3.connect(self.dbpath)
|
|
||||||
self.initdb()
|
|
||||||
else:
|
|
||||||
self.db=sqlite3.connect(self.dbpath,check_same_thread=False)
|
|
||||||
if not "'TYPE'" in str(self.db.execute("select * from stats").description):
|
|
||||||
self.updatedb()
|
|
||||||
|
|
||||||
|
|
||||||
def updatedb(self):
|
|
||||||
self.logger.debug('Update stat database')
|
|
||||||
self.db.execute("alter table stats add column TYPE TEXT;")
|
|
||||||
self.db.execute("update stats set TYPE='BACKUP';")
|
|
||||||
self.db.commit()
|
|
||||||
|
|
||||||
def initdb(self):
|
|
||||||
assert(isinstance(self.db,sqlite3.Connection))
|
|
||||||
self.logger.debug('Initialize stat database')
|
|
||||||
self.db.execute("""
|
|
||||||
create table stats (
|
|
||||||
backup_name TEXT,
|
|
||||||
server_name TEXT,
|
|
||||||
description TEXT,
|
|
||||||
backup_start TEXT,
|
|
||||||
backup_end TEXT,
|
|
||||||
backup_duration NUMERIC,
|
|
||||||
total_files_count INT,
|
|
||||||
written_files_count INT,
|
|
||||||
total_bytes INT,
|
|
||||||
written_bytes INT,
|
|
||||||
status TEXT,
|
|
||||||
log TEXT,
|
|
||||||
backup_location TEXT,
|
|
||||||
TYPE TEXT)""")
|
|
||||||
self.db.execute("""
|
|
||||||
create index idx_stats_backup_name on stats(backup_name);""")
|
|
||||||
self.db.execute("""
|
|
||||||
create index idx_stats_backup_location on stats(backup_location);""")
|
|
||||||
self.db.execute("""
|
|
||||||
CREATE INDEX idx_stats_backup_name_start on stats(backup_name,backup_start);""")
|
|
||||||
self.db.commit()
|
|
||||||
|
|
||||||
def start(self,backup_name,server_name,TYPE,description='',backup_location=None):
|
|
||||||
""" Add in stat DB a record for the newly running backup"""
|
|
||||||
return self.add(backup_name=backup_name,server_name=server_name,description=description,backup_start=datetime2isodate(),status='Running',TYPE=TYPE)
|
|
||||||
|
|
||||||
def finish(self,rowid,total_files_count=None,written_files_count=None,total_bytes=None,written_bytes=None,log=None,status='OK',backup_end=None,backup_duration=None,backup_location=None):
|
|
||||||
""" Update record in stat DB for the finished backup"""
|
|
||||||
if not backup_end:
|
|
||||||
backup_end = datetime2isodate()
|
|
||||||
if backup_duration == None:
|
|
||||||
try:
|
|
||||||
# get duration using start of backup datetime
|
|
||||||
backup_duration = (isodate2datetime(backup_end) - isodate2datetime(self.query('select backup_start from stats where rowid=?',(rowid,))[0]['backup_start'])).seconds / 3600.0
|
|
||||||
except:
|
|
||||||
backup_duration = None
|
|
||||||
|
|
||||||
# update stat record
|
|
||||||
self.db.execute("""\
|
|
||||||
update stats set
|
|
||||||
total_files_count=?,written_files_count=?,total_bytes=?,written_bytes=?,log=?,status=?,backup_end=?,backup_duration=?,backup_location=?
|
|
||||||
where
|
|
||||||
rowid = ?
|
|
||||||
""",(total_files_count,written_files_count,total_bytes,written_bytes,log,status,backup_end,backup_duration,backup_location,rowid))
|
|
||||||
self.db.commit()
|
|
||||||
|
|
||||||
def add(self,
|
|
||||||
backup_name='',
|
|
||||||
server_name='',
|
|
||||||
description='',
|
|
||||||
backup_start=None,
|
|
||||||
backup_end=None,
|
|
||||||
backup_duration=None,
|
|
||||||
total_files_count=None,
|
|
||||||
written_files_count=None,
|
|
||||||
total_bytes=None,
|
|
||||||
written_bytes=None,
|
|
||||||
status='draft',
|
|
||||||
log='',
|
|
||||||
TYPE='',
|
|
||||||
backup_location=None):
|
|
||||||
if not backup_start:
|
|
||||||
backup_start=datetime2isodate()
|
|
||||||
if not backup_end:
|
|
||||||
backup_end=datetime2isodate()
|
|
||||||
|
|
||||||
cur = self.db.execute("""\
|
|
||||||
insert into stats (
|
|
||||||
backup_name,
|
|
||||||
server_name,
|
|
||||||
description,
|
|
||||||
backup_start,
|
|
||||||
backup_end,
|
|
||||||
backup_duration,
|
|
||||||
total_files_count,
|
|
||||||
written_files_count,
|
|
||||||
total_bytes,
|
|
||||||
written_bytes,
|
|
||||||
status,
|
|
||||||
log,
|
|
||||||
backup_location,
|
|
||||||
TYPE) values (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
||||||
""",(
|
|
||||||
backup_name,
|
|
||||||
server_name,
|
|
||||||
description,
|
|
||||||
backup_start,
|
|
||||||
backup_end,
|
|
||||||
backup_duration,
|
|
||||||
total_files_count,
|
|
||||||
written_files_count,
|
|
||||||
total_bytes,
|
|
||||||
written_bytes,
|
|
||||||
status,
|
|
||||||
log,
|
|
||||||
backup_location,
|
|
||||||
TYPE)
|
|
||||||
)
|
|
||||||
|
|
||||||
self.db.commit()
|
|
||||||
return cur.lastrowid
|
|
||||||
|
|
||||||
def query(self,query, args=(), one=False):
|
|
||||||
"""
|
|
||||||
execute la requete query sur la db et renvoie un tableau de dictionnaires
|
|
||||||
"""
|
|
||||||
cur = self.db.execute(query, args)
|
|
||||||
rv = [dict((cur.description[idx][0], value)
|
|
||||||
for idx, value in enumerate(row)) for row in cur.fetchall()]
|
|
||||||
return (rv[0] if rv else None) if one else rv
|
|
||||||
|
|
||||||
def last_backups(self,backup_name,count=30):
|
|
||||||
if backup_name:
|
|
||||||
cur = self.db.execute('select * from stats where backup_name=? order by backup_end desc limit ?',(backup_name,count))
|
|
||||||
else:
|
|
||||||
cur = self.db.execute('select * from stats order by backup_end desc limit ?',(count,))
|
|
||||||
|
|
||||||
def fcb(fieldname,value):
|
|
||||||
if fieldname in ('backup_start','backup_end'):
|
|
||||||
return time2display(isodate2datetime(value))
|
|
||||||
elif 'bytes' in fieldname:
|
|
||||||
return convert_bytes(value)
|
|
||||||
elif 'count' in fieldname:
|
|
||||||
return splitThousands(value,' ','.')
|
|
||||||
elif 'backup_duration' in fieldname:
|
|
||||||
return hours_minutes(value)
|
|
||||||
else:
|
|
||||||
return value
|
|
||||||
|
|
||||||
#for r in self.query('select * from stats where backup_name=? order by backup_end desc limit ?',(backup_name,count)):
|
|
||||||
print((pp(cur,None,1,fcb)))
|
|
||||||
|
|
||||||
|
|
||||||
def fcb(self,fields,fieldname,value):
|
|
||||||
if fieldname in ('backup_start','backup_end'):
|
|
||||||
return time2display(isodate2datetime(value))
|
|
||||||
elif 'bytes' in fieldname:
|
|
||||||
return convert_bytes(value)
|
|
||||||
elif 'count' in fieldname:
|
|
||||||
return splitThousands(value,' ','.')
|
|
||||||
elif 'backup_duration' in fieldname:
|
|
||||||
return hours_minutes(value)
|
|
||||||
else:
|
|
||||||
return value
|
|
||||||
|
|
||||||
def as_html(self,cur):
|
|
||||||
if cur:
|
|
||||||
return html_table(cur,self.fcb)
|
|
||||||
else:
|
|
||||||
return html_table(self.db.execute('select * from stats order by backup_start asc'),self.fcb)
|
|
||||||
|
|
||||||
|
|
||||||
def ssh_exec(command,ssh=None,server_name='',remote_user='',private_key='',ssh_port=22):
|
|
||||||
"""execute command on server_name using the provided ssh connection
|
|
||||||
or creates a new connection if ssh is not provided.
|
|
||||||
returns (exit_code,output)
|
|
||||||
|
|
||||||
output is the concatenation of stdout and stderr
|
|
||||||
"""
|
|
||||||
if not ssh:
|
|
||||||
assert(server_name and remote_user and private_key)
|
|
||||||
try:
|
|
||||||
mykey = paramiko.RSAKey.from_private_key_file(private_key)
|
|
||||||
except paramiko.SSHException:
|
|
||||||
#mykey = paramiko.DSSKey.from_private_key_file(private_key)
|
|
||||||
mykey = paramiko.Ed25519Key.from_private_key_file(self.private_key)
|
|
||||||
|
|
||||||
ssh = paramiko.SSHClient()
|
|
||||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
||||||
ssh.connect(server_name,username=remote_user,pkey = mykey,port=ssh_port)
|
|
||||||
|
|
||||||
tran = ssh.get_transport()
|
|
||||||
chan = tran.open_session()
|
|
||||||
|
|
||||||
# chan.set_combine_stderr(True)
|
|
||||||
chan.get_pty()
|
|
||||||
stdout = chan.makefile()
|
|
||||||
|
|
||||||
chan.exec_command(command)
|
|
||||||
stdout.flush()
|
|
||||||
output_base = stdout.read()
|
|
||||||
output = output_base.decode(errors='ignore').replace("'","")
|
|
||||||
exit_code = chan.recv_exit_status()
|
|
||||||
return (exit_code,output)
|
|
||||||
|
|
||||||
|
|
||||||
class backup_generic(ABC):
|
|
||||||
"""Generic ancestor class for backups, not registered"""
|
|
||||||
type = 'generic'
|
|
||||||
required_params = ['type','backup_name','backup_dir','server_name','backup_retention_time','maximum_backup_age']
|
|
||||||
optional_params = ['preexec','postexec','description','private_key','remote_user','ssh_port']
|
|
||||||
|
|
||||||
logger = logging.getLogger('tisbackup')
|
|
||||||
backup_name = ''
|
|
||||||
backup_dir = ''
|
|
||||||
server_name = ''
|
|
||||||
remote_user = 'root'
|
|
||||||
description = ''
|
|
||||||
dbstat = None
|
|
||||||
dry_run = False
|
|
||||||
preexec = ''
|
|
||||||
postexec = ''
|
|
||||||
maximum_backup_age = None
|
|
||||||
backup_retention_time = None
|
|
||||||
verbose = False
|
|
||||||
private_key=''
|
|
||||||
ssh_port=22
|
|
||||||
|
|
||||||
def __init__(self,backup_name, backup_dir,dbstat=None,dry_run=False):
|
|
||||||
if not re.match('^[A-Za-z0-9_\-\.]*$',backup_name):
|
|
||||||
raise Exception('The backup name %s should contain only alphanumerical characters' % backup_name)
|
|
||||||
self.backup_name = backup_name
|
|
||||||
self.backup_dir = backup_dir
|
|
||||||
|
|
||||||
self.dbstat = dbstat
|
|
||||||
assert(isinstance(self.dbstat,BackupStat) or self.dbstat==None)
|
|
||||||
|
|
||||||
if not os.path.isdir(self.backup_dir):
|
|
||||||
os.makedirs(self.backup_dir)
|
|
||||||
|
|
||||||
self.dry_run = dry_run
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_help(cls):
|
|
||||||
return """\
|
|
||||||
%(type)s : %(desc)s
|
|
||||||
Required params : %(required)s
|
|
||||||
Optional params : %(optional)s
|
|
||||||
""" % {'type':cls.type,
|
|
||||||
'desc':cls.__doc__,
|
|
||||||
'required':",".join(cls.required_params),
|
|
||||||
'optional':",".join(cls.optional_params)}
|
|
||||||
|
|
||||||
def check_required_params(self):
|
|
||||||
for name in self.required_params:
|
|
||||||
if not hasattr(self,name) or not getattr(self,name):
|
|
||||||
raise Exception('[%s] Config Attribute %s is required' % (self.backup_name,name))
|
|
||||||
if (self.preexec or self.postexec) and (not self.private_key or not self.remote_user):
|
|
||||||
raise Exception('[%s] remote_user and private_key file required if preexec or postexec is used' % self.backup_name)
|
|
||||||
|
|
||||||
|
|
||||||
def read_config(self,iniconf):
|
|
||||||
assert(isinstance(iniconf,ConfigParser))
|
|
||||||
allowed_params = self.required_params+self.optional_params
|
|
||||||
for (name,value) in iniconf.items(self.backup_name):
|
|
||||||
if not name in allowed_params:
|
|
||||||
self.logger.critical('[%s] Invalid param name "%s"', self.backup_name,name);
|
|
||||||
raise Exception('[%s] Invalid param name "%s"', self.backup_name,name)
|
|
||||||
self.logger.debug('[%s] reading param %s = %s ', self.backup_name,name,value)
|
|
||||||
setattr(self,name,value)
|
|
||||||
|
|
||||||
# if retention (in days) is not defined at section level, get default global one.
|
|
||||||
if not self.backup_retention_time:
|
|
||||||
self.backup_retention_time = iniconf.getint('global','backup_retention_time')
|
|
||||||
|
|
||||||
# for nagios, if maximum last backup age (in hours) is not defined at section level, get default global one.
|
|
||||||
if not self.maximum_backup_age:
|
|
||||||
self.maximum_backup_age = iniconf.getint('global','maximum_backup_age')
|
|
||||||
|
|
||||||
self.ssh_port = int(self.ssh_port)
|
|
||||||
self.backup_retention_time = int(self.backup_retention_time)
|
|
||||||
self.maximum_backup_age = int(self.maximum_backup_age)
|
|
||||||
|
|
||||||
self.check_required_params()
|
|
||||||
|
|
||||||
|
|
||||||
def do_preexec(self,stats):
|
|
||||||
self.logger.info("[%s] executing preexec %s ",self.backup_name,self.preexec)
|
|
||||||
try:
|
|
||||||
mykey = paramiko.RSAKey.from_private_key_file(self.private_key)
|
|
||||||
except paramiko.SSHException:
|
|
||||||
mykey = paramiko.DSSKey.from_private_key_file(self.private_key)
|
|
||||||
|
|
||||||
ssh = paramiko.SSHClient()
|
|
||||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
||||||
ssh.connect(self.server_name,username=self.remote_user,pkey = mykey)
|
|
||||||
tran = ssh.get_transport()
|
|
||||||
chan = tran.open_session()
|
|
||||||
|
|
||||||
# chan.set_combine_stderr(True)
|
|
||||||
chan.get_pty()
|
|
||||||
stdout = chan.makefile()
|
|
||||||
|
|
||||||
if not self.dry_run:
|
|
||||||
chan.exec_command(self.preexec)
|
|
||||||
output = stdout.read()
|
|
||||||
exit_code = chan.recv_exit_status()
|
|
||||||
self.logger.info('[%s] preexec exit code : "%i", output : %s',self.backup_name , exit_code, output )
|
|
||||||
return exit_code
|
|
||||||
else:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
def do_postexec(self,stats):
|
|
||||||
self.logger.info("[%s] executing postexec %s ",self.backup_name,self.postexec)
|
|
||||||
try:
|
|
||||||
mykey = paramiko.RSAKey.from_private_key_file(self.private_key)
|
|
||||||
except paramiko.SSHException:
|
|
||||||
mykey = paramiko.DSSKey.from_private_key_file(self.private_key)
|
|
||||||
|
|
||||||
ssh = paramiko.SSHClient()
|
|
||||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
||||||
ssh.connect(self.server_name,username=self.remote_user,pkey = mykey)
|
|
||||||
tran = ssh.get_transport()
|
|
||||||
chan = tran.open_session()
|
|
||||||
|
|
||||||
# chan.set_combine_stderr(True)
|
|
||||||
chan.get_pty()
|
|
||||||
stdout = chan.makefile()
|
|
||||||
|
|
||||||
if not self.dry_run:
|
|
||||||
chan.exec_command(self.postexec)
|
|
||||||
output = stdout.read()
|
|
||||||
exit_code = chan.recv_exit_status()
|
|
||||||
self.logger.info('[%s] postexec exit code : "%i", output : %s',self.backup_name , exit_code, output )
|
|
||||||
return exit_code
|
|
||||||
else:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def do_backup(self,stats):
|
|
||||||
"""stats dict with keys : total_files_count,written_files_count,total_bytes,written_bytes"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
def check_params_connections(self):
|
|
||||||
"""Perform a dry run trying to connect without actually doing backup"""
|
|
||||||
self.check_required_params()
|
|
||||||
|
|
||||||
def process_backup(self):
|
|
||||||
"""Process the backup.
|
|
||||||
launch
|
|
||||||
- do_preexec
|
|
||||||
- do_backup
|
|
||||||
- do_postexec
|
|
||||||
|
|
||||||
returns a dict for stats
|
|
||||||
"""
|
|
||||||
self.logger.info('[%s] ######### Starting backup',self.backup_name)
|
|
||||||
|
|
||||||
starttime = time.time()
|
|
||||||
self.backup_start_date = datetime.datetime.now().strftime('%Y%m%d-%Hh%Mm%S')
|
|
||||||
|
|
||||||
if not self.dry_run and self.dbstat:
|
|
||||||
stat_rowid = self.dbstat.start(backup_name=self.backup_name,server_name=self.server_name,TYPE="BACKUP")
|
|
||||||
else:
|
|
||||||
stat_rowid = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
stats = {}
|
|
||||||
stats['total_files_count']=0
|
|
||||||
stats['written_files_count']=0
|
|
||||||
stats['total_bytes']=0
|
|
||||||
stats['written_bytes']=0
|
|
||||||
stats['log']=''
|
|
||||||
stats['status']='Running'
|
|
||||||
stats['backup_location']=None
|
|
||||||
|
|
||||||
if self.preexec.strip():
|
|
||||||
exit_code = self.do_preexec(stats)
|
|
||||||
if exit_code != 0 :
|
|
||||||
raise Exception('Preexec "%s" failed with exit code "%i"' % (self.preexec,exit_code))
|
|
||||||
|
|
||||||
self.do_backup(stats)
|
|
||||||
|
|
||||||
if self.postexec.strip():
|
|
||||||
exit_code = self.do_postexec(stats)
|
|
||||||
if exit_code != 0 :
|
|
||||||
raise Exception('Postexec "%s" failed with exit code "%i"' % (self.postexec,exit_code))
|
|
||||||
|
|
||||||
endtime = time.time()
|
|
||||||
duration = (endtime-starttime)/3600.0
|
|
||||||
if not self.dry_run and self.dbstat:
|
|
||||||
self.dbstat.finish(stat_rowid,
|
|
||||||
backup_end=datetime2isodate(datetime.datetime.now()),
|
|
||||||
backup_duration = duration,
|
|
||||||
total_files_count=stats['total_files_count'],
|
|
||||||
written_files_count=stats['written_files_count'],
|
|
||||||
total_bytes=stats['total_bytes'],
|
|
||||||
written_bytes=stats['written_bytes'],
|
|
||||||
status=stats['status'],
|
|
||||||
log=stats['log'],
|
|
||||||
backup_location=stats['backup_location'])
|
|
||||||
|
|
||||||
self.logger.info('[%s] ######### Backup finished : %s',self.backup_name,stats['log'])
|
|
||||||
return stats
|
|
||||||
|
|
||||||
except BaseException as e:
|
|
||||||
stats['status']='ERROR'
|
|
||||||
stats['log']=str(e)
|
|
||||||
endtime = time.time()
|
|
||||||
duration = (endtime-starttime)/3600.0
|
|
||||||
if not self.dry_run and self.dbstat:
|
|
||||||
self.dbstat.finish(stat_rowid,
|
|
||||||
backup_end=datetime2isodate(datetime.datetime.now()),
|
|
||||||
backup_duration = duration,
|
|
||||||
total_files_count=stats['total_files_count'],
|
|
||||||
written_files_count=stats['written_files_count'],
|
|
||||||
total_bytes=stats['total_bytes'],
|
|
||||||
written_bytes=stats['written_bytes'],
|
|
||||||
status=stats['status'],
|
|
||||||
log=stats['log'],
|
|
||||||
backup_location=stats['backup_location'])
|
|
||||||
|
|
||||||
self.logger.error('[%s] ######### Backup finished with ERROR: %s',self.backup_name,stats['log'])
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
def checknagios(self):
|
|
||||||
"""
|
|
||||||
Returns a tuple (nagiosstatus,message) for the current backup_name
|
|
||||||
Read status from dbstat database
|
|
||||||
"""
|
|
||||||
if not self.dbstat:
|
|
||||||
self.logger.warn('[%s] checknagios : no database provided',self.backup_name)
|
|
||||||
return ('No database provided',nagiosStateUnknown)
|
|
||||||
else:
|
|
||||||
self.logger.debug('[%s] checknagios : sql query "%s" %s',self.backup_name,'select status, backup_end, log from stats where TYPE=\'BACKUP\' AND backup_name=? order by backup_end desc limit 30',self.backup_name)
|
|
||||||
q = self.dbstat.query('select status, backup_start, backup_end, log, backup_location, total_bytes from stats where TYPE=\'BACKUP\' AND backup_name=? order by backup_start desc limit 30',(self.backup_name,))
|
|
||||||
if not q:
|
|
||||||
self.logger.debug('[%s] checknagios : no result from query',self.backup_name)
|
|
||||||
return (nagiosStateCritical,'CRITICAL : No backup found for %s in database' % self.backup_name)
|
|
||||||
else:
|
|
||||||
mindate = datetime2isodate((datetime.datetime.now() - datetime.timedelta(hours=self.maximum_backup_age)))
|
|
||||||
self.logger.debug('[%s] checknagios : looking for most recent OK not older than %s',self.backup_name,mindate)
|
|
||||||
for b in q:
|
|
||||||
if b['backup_end'] >= mindate and b['status'] == 'OK':
|
|
||||||
# check if backup actually exists on registered backup location and is newer than backup start date
|
|
||||||
if b['total_bytes'] == 0:
|
|
||||||
return (nagiosStateWarning,"WARNING : No data to backup was found for %s" % (self.backup_name,))
|
|
||||||
|
|
||||||
if not b['backup_location']:
|
|
||||||
return (nagiosStateWarning,"WARNING : No Backup location found for %s finished on (%s) %s" % (self.backup_name,isodate2datetime(b['backup_end']),b['log']))
|
|
||||||
|
|
||||||
if os.path.isfile(b['backup_location']):
|
|
||||||
backup_actual_date = datetime.datetime.fromtimestamp(os.stat(b['backup_location']).st_ctime)
|
|
||||||
if backup_actual_date + datetime.timedelta(hours = 1) > isodate2datetime(b['backup_start']):
|
|
||||||
return (nagiosStateOk,"OK Backup %s (%s), %s" % (self.backup_name,isodate2datetime(b['backup_end']),b['log']))
|
|
||||||
else:
|
|
||||||
return (nagiosStateCritical,"CRITICAL Backup %s (%s), %s seems older than start of backup" % (self.backup_name,isodate2datetime(b['backup_end']),b['log']))
|
|
||||||
elif os.path.isdir(b['backup_location']):
|
|
||||||
return (nagiosStateOk,"OK Backup %s (%s), %s" % (self.backup_name,isodate2datetime(b['backup_end']),b['log']))
|
|
||||||
elif self.type == 'copy-vm-xcp':
|
|
||||||
return (nagiosStateOk,"OK Backup %s (%s), %s" % (self.backup_name,isodate2datetime(b['backup_end']),b['log']))
|
|
||||||
else:
|
|
||||||
return (nagiosStateCritical,"CRITICAL Backup %s (%s), %s has disapeared from backup location %s" % (self.backup_name,isodate2datetime(b['backup_end']),b['log'],b['backup_location']))
|
|
||||||
|
|
||||||
self.logger.debug('[%s] checknagios : looking for most recent Warning or Running not older than %s',self.backup_name,mindate)
|
|
||||||
for b in q:
|
|
||||||
if b['backup_end'] >= mindate and b['status'] in ('Warning','Running'):
|
|
||||||
return (nagiosStateWarning,'WARNING : Backup %s still running or warning. %s' % (self.backup_name,b['log']))
|
|
||||||
|
|
||||||
self.logger.debug('[%s] checknagios : No Ok or warning recent backup found',self.backup_name)
|
|
||||||
return (nagiosStateCritical,'CRITICAL : No recent backup for %s' % self.backup_name )
|
|
||||||
|
|
||||||
def cleanup_backup(self):
|
|
||||||
"""Removes obsolete backups (older than backup_retention_time)"""
|
|
||||||
mindate = datetime2isodate((dateof(datetime.datetime.now()) - datetime.timedelta(days=self.backup_retention_time)))
|
|
||||||
# check if there is at least 1 "OK" backup left after cleanup :
|
|
||||||
ok_backups = self.dbstat.query('select backup_location from stats where TYPE="BACKUP" and backup_name=? and backup_start>=? and status="OK" order by backup_start desc',(self.backup_name,mindate))
|
|
||||||
removed = []
|
|
||||||
if ok_backups and os.path.exists(ok_backups[0]['backup_location']):
|
|
||||||
records = self.dbstat.query('select status, backup_start, backup_end, log, backup_location from stats where backup_name=? and backup_start<? and backup_location is not null and TYPE="BACKUP" order by backup_start',(self.backup_name,mindate))
|
|
||||||
if records:
|
|
||||||
for oldbackup_location in [rec['backup_location'] for rec in records if rec['backup_location']]:
|
|
||||||
try:
|
|
||||||
if os.path.isdir(oldbackup_location) and self.backup_dir in oldbackup_location :
|
|
||||||
self.logger.info('[%s] removing directory "%s"',self.backup_name,oldbackup_location)
|
|
||||||
if not self.dry_run:
|
|
||||||
if self.type =="rsync+btrfs+ssh" or self.type == "rsync+btrfs":
|
|
||||||
cmd = "/bin/btrfs subvolume delete %s"%oldbackup_location
|
|
||||||
process = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, close_fds=True)
|
|
||||||
log = monitor_stdout(process,'',self)
|
|
||||||
returncode = process.returncode
|
|
||||||
if (returncode != 0):
|
|
||||||
self.logger.error("[" + self.backup_name + "] shell program exited with error code: %s"%log)
|
|
||||||
raise Exception("[" + self.backup_name + "] shell program exited with error code " + str(returncode), cmd)
|
|
||||||
else:
|
|
||||||
self.logger.info("[" + self.backup_name + "] deleting snapshot volume: %s"%oldbackup_location.encode('ascii'))
|
|
||||||
else:
|
|
||||||
shutil.rmtree(oldbackup_location.encode('ascii'))
|
|
||||||
if os.path.isfile(oldbackup_location) and self.backup_dir in oldbackup_location :
|
|
||||||
self.logger.debug('[%s] removing file "%s"',self.backup_name,oldbackup_location)
|
|
||||||
if not self.dry_run:
|
|
||||||
os.remove(oldbackup_location)
|
|
||||||
self.logger.debug('Cleanup_backup : Removing records from DB : [%s]-"%s"',self.backup_name,oldbackup_location)
|
|
||||||
if not self.dry_run:
|
|
||||||
self.dbstat.db.execute('update stats set TYPE="CLEAN" where backup_name=? and backup_location=?',(self.backup_name,oldbackup_location))
|
|
||||||
self.dbstat.db.commit()
|
|
||||||
except BaseException as e:
|
|
||||||
self.logger.error('cleanup_backup : Unable to remove directory/file "%s". Error %s', oldbackup_location,e)
|
|
||||||
removed.append((self.backup_name,oldbackup_location))
|
|
||||||
else:
|
|
||||||
self.logger.debug('[%s] cleanup : no result for query',self.backup_name)
|
|
||||||
else:
|
|
||||||
self.logger.info('Nothing to do because we want to keep at least one OK backup after cleaning')
|
|
||||||
|
|
||||||
self.logger.info('[%s] Cleanup finished : removed : %s' , self.backup_name,','.join([('[%s]-"%s"') % r for r in removed]) or 'Nothing')
|
|
||||||
return removed
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def register_existingbackups(self):
|
|
||||||
pass
|
|
||||||
# """scan existing backups and insert stats in database"""
|
|
||||||
# registered = [b['backup_location'] for b in self.dbstat.query('select distinct backup_location from stats where backup_name=?',[self.backup_name])]
|
|
||||||
# raise Exception('Abstract method')
|
|
||||||
|
|
||||||
def export_latestbackup(self,destdir):
|
|
||||||
"""Copy (rsync) latest OK backup to external storage located at locally mounted "destdir"
|
|
||||||
"""
|
|
||||||
stats = {}
|
|
||||||
stats['total_files_count']=0
|
|
||||||
stats['written_files_count']=0
|
|
||||||
stats['total_bytes']=0
|
|
||||||
stats['written_bytes']=0
|
|
||||||
stats['log']=''
|
|
||||||
stats['status']='Running'
|
|
||||||
if not self.dbstat:
|
|
||||||
self.logger.critical('[%s] export_latestbackup : no database provided',self.backup_name)
|
|
||||||
raise Exception('No database')
|
|
||||||
else:
|
|
||||||
latest_sql = """\
|
|
||||||
select status, backup_start, backup_end, log, backup_location, total_bytes
|
|
||||||
from stats
|
|
||||||
where backup_name=? and status='OK' and TYPE='BACKUP'
|
|
||||||
order by backup_start desc limit 30"""
|
|
||||||
self.logger.debug('[%s] export_latestbackup : sql query "%s" %s',self.backup_name,latest_sql,self.backup_name)
|
|
||||||
q = self.dbstat.query(latest_sql,(self.backup_name,))
|
|
||||||
if not q:
|
|
||||||
self.logger.debug('[%s] export_latestbackup : no result from query',self.backup_name)
|
|
||||||
raise Exception('No OK backup found for %s in database' % self.backup_name)
|
|
||||||
else:
|
|
||||||
latest = q[0]
|
|
||||||
backup_source = latest['backup_location']
|
|
||||||
backup_dest = os.path.join(os.path.abspath(destdir),self.backup_name)
|
|
||||||
if not os.path.exists(backup_source):
|
|
||||||
raise Exception('Backup source %s doesn\'t exists' % backup_source)
|
|
||||||
|
|
||||||
# ensure there is a slash at end
|
|
||||||
if os.path.isdir(backup_source) and backup_source[-1] != '/':
|
|
||||||
backup_source += '/'
|
|
||||||
if backup_dest[-1] != '/':
|
|
||||||
backup_dest += '/'
|
|
||||||
|
|
||||||
if not os.path.isdir(backup_dest):
|
|
||||||
os.makedirs(backup_dest)
|
|
||||||
|
|
||||||
options = ['-aP','--stats','--delete-excluded','--numeric-ids','--delete-after']
|
|
||||||
if self.logger.level:
|
|
||||||
options.append('-P')
|
|
||||||
|
|
||||||
if self.dry_run:
|
|
||||||
options.append('-d')
|
|
||||||
|
|
||||||
options_params = " ".join(options)
|
|
||||||
|
|
||||||
cmd = '/usr/bin/rsync %s %s %s 2>&1' % (options_params,backup_source,backup_dest)
|
|
||||||
self.logger.debug("[%s] rsync : %s",self.backup_name,cmd)
|
|
||||||
|
|
||||||
if not self.dry_run:
|
|
||||||
self.line = ''
|
|
||||||
starttime = time.time()
|
|
||||||
stat_rowid = self.dbstat.start(backup_name=self.backup_name,server_name=self.server_name, TYPE="EXPORT")
|
|
||||||
|
|
||||||
process = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, close_fds=True)
|
|
||||||
def ondata(data,context):
|
|
||||||
if context.verbose:
|
|
||||||
print(data)
|
|
||||||
context.logger.debug(data)
|
|
||||||
|
|
||||||
log = monitor_stdout(process,ondata,self)
|
|
||||||
|
|
||||||
for l in log.splitlines():
|
|
||||||
if l.startswith('Number of files:'):
|
|
||||||
stats['total_files_count'] += int(re.findall('[0-9]+', l.split(':')[1])[0])
|
|
||||||
if l.startswith('Number of files transferred:'):
|
|
||||||
stats['written_files_count'] += int(l.split(':')[1])
|
|
||||||
if l.startswith('Total file size:'):
|
|
||||||
stats['total_bytes'] += float(l.replace(',','').split(':')[1].split()[0])
|
|
||||||
if l.startswith('Total transferred file size:'):
|
|
||||||
stats['written_bytes'] += float(l.replace(',','').split(':')[1].split()[0])
|
|
||||||
returncode = process.returncode
|
|
||||||
## deal with exit code 24 (file vanished)
|
|
||||||
if (returncode == 24):
|
|
||||||
self.logger.warning("[" + self.backup_name + "] Note: some files vanished before transfer")
|
|
||||||
elif (returncode == 23):
|
|
||||||
self.logger.warning("[" + self.backup_name + "] unable so set uid on some files")
|
|
||||||
elif (returncode != 0):
|
|
||||||
self.logger.error("[" + self.backup_name + "] shell program exited with error code ")
|
|
||||||
raise Exception("[" + self.backup_name + "] shell program exited with error code " + str(returncode), cmd)
|
|
||||||
else:
|
|
||||||
print(cmd)
|
|
||||||
|
|
||||||
stats['status']='OK'
|
|
||||||
self.logger.info('export backup from %s to %s OK, %d bytes written for %d changed files' % (backup_source,backup_dest,stats['written_bytes'],stats['written_files_count']))
|
|
||||||
|
|
||||||
endtime = time.time()
|
|
||||||
duration = (endtime-starttime)/3600.0
|
|
||||||
if not self.dry_run and self.dbstat:
|
|
||||||
self.dbstat.finish(stat_rowid,
|
|
||||||
backup_end=datetime2isodate(datetime.datetime.now()),
|
|
||||||
backup_duration = duration,
|
|
||||||
total_files_count=stats['total_files_count'],
|
|
||||||
written_files_count=stats['written_files_count'],
|
|
||||||
total_bytes=stats['total_bytes'],
|
|
||||||
written_bytes=stats['written_bytes'],
|
|
||||||
status=stats['status'],
|
|
||||||
log=stats['log'],
|
|
||||||
backup_location=backup_dest)
|
|
||||||
return stats
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
logger = logging.getLogger('tisbackup')
|
|
||||||
logger.setLevel(logging.DEBUG)
|
|
||||||
formatter = logging.Formatter('%(asctime)s %(levelname)s %(message)s')
|
|
||||||
handler = logging.StreamHandler()
|
|
||||||
handler.setFormatter(formatter)
|
|
||||||
logger.addHandler(handler)
|
|
||||||
dbstat = BackupStat('/backup/data/log/tisbackup.sqlite')
|
|
||||||
@@ -1,295 +0,0 @@
|
|||||||
#!/usr/bin/python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# This file is part of TISBackup
|
|
||||||
#
|
|
||||||
# TISBackup is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# TISBackup is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with TISBackup. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
#
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
import os
|
|
||||||
import datetime
|
|
||||||
from .common import *
|
|
||||||
from . import XenAPI
|
|
||||||
import time
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
import os.path
|
|
||||||
import os
|
|
||||||
import datetime
|
|
||||||
import select
|
|
||||||
import urllib.request, urllib.error, urllib.parse
|
|
||||||
import base64
|
|
||||||
import socket
|
|
||||||
from stat import *
|
|
||||||
import ssl
|
|
||||||
if hasattr(ssl, '_create_unverified_context'):
|
|
||||||
ssl._create_default_https_context = ssl._create_unverified_context
|
|
||||||
|
|
||||||
|
|
||||||
class copy_vm_xcp(backup_generic):
|
|
||||||
"""Backup a VM running on a XCP server on a second SR (requires xe tools and XenAPI)"""
|
|
||||||
type = 'copy-vm-xcp'
|
|
||||||
|
|
||||||
required_params = backup_generic.required_params + ['server_name','storage_name','password_file','vm_name','network_name']
|
|
||||||
optional_params = backup_generic.optional_params + ['start_vm','max_copies', 'delete_snapshot', 'halt_vm']
|
|
||||||
|
|
||||||
start_vm = "no"
|
|
||||||
max_copies = 1
|
|
||||||
halt_vm = "no"
|
|
||||||
delete_snapshot = "yes"
|
|
||||||
|
|
||||||
def read_config(self,iniconf):
|
|
||||||
assert(isinstance(iniconf,ConfigParser))
|
|
||||||
backup_generic.read_config(self,iniconf)
|
|
||||||
if self.start_vm in 'no' and iniconf.has_option('global','start_vm'):
|
|
||||||
self.start_vm = iniconf.get('global','start_vm')
|
|
||||||
if self.max_copies == 1 and iniconf.has_option('global','max_copies'):
|
|
||||||
self.max_copies = iniconf.getint('global','max_copies')
|
|
||||||
if self.delete_snapshot == "yes" and iniconf.has_option('global','delete_snapshot'):
|
|
||||||
self.delete_snapshot = iniconf.get('global','delete_snapshot')
|
|
||||||
|
|
||||||
|
|
||||||
def copy_vm_to_sr(self, vm_name, storage_name, dry_run, delete_snapshot="yes"):
|
|
||||||
user_xen, password_xen, null = open(self.password_file).read().split('\n')
|
|
||||||
session = XenAPI.Session('https://'+self.server_name)
|
|
||||||
try:
|
|
||||||
session.login_with_password(user_xen,password_xen)
|
|
||||||
except XenAPI.Failure as error:
|
|
||||||
msg,ip = error.details
|
|
||||||
|
|
||||||
if msg == 'HOST_IS_SLAVE':
|
|
||||||
server_name = ip
|
|
||||||
session = XenAPI.Session('https://'+server_name)
|
|
||||||
session.login_with_password(user_xen,password_xen)
|
|
||||||
|
|
||||||
|
|
||||||
self.logger.debug("[%s] VM (%s) to backup in storage: %s",self.backup_name,vm_name,storage_name)
|
|
||||||
now = datetime.datetime.now()
|
|
||||||
|
|
||||||
#get storage opaqueRef
|
|
||||||
try:
|
|
||||||
storage = session.xenapi.SR.get_by_name_label(storage_name)[0]
|
|
||||||
except IndexError as error:
|
|
||||||
result = (1,"error get SR opaqueref %s"%(error))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
#get vm to copy opaqueRef
|
|
||||||
try:
|
|
||||||
vm = session.xenapi.VM.get_by_name_label(vm_name)[0]
|
|
||||||
except IndexError as error:
|
|
||||||
result = (1,"error get VM opaqueref %s"%(error))
|
|
||||||
return result
|
|
||||||
|
|
||||||
# get vm backup network opaqueRef
|
|
||||||
try:
|
|
||||||
networkRef = session.xenapi.network.get_by_name_label(self.network_name)[0]
|
|
||||||
except IndexError as error:
|
|
||||||
result = (1, "error get VM network opaqueref %s" % (error))
|
|
||||||
return result
|
|
||||||
|
|
||||||
if str2bool(self.halt_vm):
|
|
||||||
status_vm = session.xenapi.VM.get_power_state(vm)
|
|
||||||
self.logger.debug("[%s] Status of VM: %s",self.backup_name,status_vm)
|
|
||||||
if status_vm == "Running":
|
|
||||||
self.logger.debug("[%s] Shutdown in progress",self.backup_name)
|
|
||||||
if dry_run:
|
|
||||||
print("session.xenapi.VM.clean_shutdown(vm)")
|
|
||||||
else:
|
|
||||||
session.xenapi.VM.clean_shutdown(vm)
|
|
||||||
snapshot = vm
|
|
||||||
else:
|
|
||||||
#do the snapshot
|
|
||||||
self.logger.debug("[%s] Snapshot in progress",self.backup_name)
|
|
||||||
try:
|
|
||||||
snapshot = session.xenapi.VM.snapshot(vm,"tisbackup-%s"%(vm_name))
|
|
||||||
except XenAPI.Failure as error:
|
|
||||||
result = (1,"error when snapshot %s"%(error))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
#get snapshot opaqueRef
|
|
||||||
snapshot = session.xenapi.VM.get_by_name_label("tisbackup-%s"%(vm_name))[0]
|
|
||||||
session.xenapi.VM.set_name_description(snapshot,"snapshot created by tisbackup on : %s"%(now.strftime("%Y-%m-%d %H:%M")))
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
vm_backup_name = "zzz-%s-"%(vm_name)
|
|
||||||
|
|
||||||
|
|
||||||
#Check if old backup exit
|
|
||||||
list_backups = []
|
|
||||||
for vm_ref in session.xenapi.VM.get_all():
|
|
||||||
name_lablel = session.xenapi.VM.get_name_label(vm_ref)
|
|
||||||
if vm_backup_name in name_lablel:
|
|
||||||
list_backups.append(name_lablel)
|
|
||||||
|
|
||||||
list_backups.sort()
|
|
||||||
|
|
||||||
if len(list_backups) >= 1:
|
|
||||||
|
|
||||||
# Shutting last backup if started
|
|
||||||
last_backup_vm = session.xenapi.VM.get_by_name_label(list_backups[-1])[0]
|
|
||||||
if not "Halted" in session.xenapi.VM.get_power_state(last_backup_vm):
|
|
||||||
self.logger.debug("[%s] Shutting down last backup vm : %s", self.backup_name, list_backups[-1] )
|
|
||||||
session.xenapi.VM.hard_shutdown(last_backup_vm)
|
|
||||||
|
|
||||||
# Delete oldest backup if exist
|
|
||||||
if len(list_backups) >= int(self.max_copies):
|
|
||||||
for i in range(len(list_backups)-int(self.max_copies)+1):
|
|
||||||
oldest_backup_vm = session.xenapi.VM.get_by_name_label(list_backups[i])[0]
|
|
||||||
if not "Halted" in session.xenapi.VM.get_power_state(oldest_backup_vm):
|
|
||||||
self.logger.debug("[%s] Shutting down old vm : %s", self.backup_name, list_backups[i] )
|
|
||||||
session.xenapi.VM.hard_shutdown(oldest_backup_vm)
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.logger.debug("[%s] Deleting old vm : %s", self.backup_name, list_backups[i])
|
|
||||||
for vbd in session.xenapi.VM.get_VBDs(oldest_backup_vm):
|
|
||||||
if session.xenapi.VBD.get_type(vbd) == 'CD'and session.xenapi.VBD.get_record(vbd)['empty'] == False:
|
|
||||||
session.xenapi.VBD.eject(vbd)
|
|
||||||
else:
|
|
||||||
vdi = session.xenapi.VBD.get_VDI(vbd)
|
|
||||||
if not 'NULL' in vdi:
|
|
||||||
session.xenapi.VDI.destroy(vdi)
|
|
||||||
|
|
||||||
session.xenapi.VM.destroy(oldest_backup_vm)
|
|
||||||
except XenAPI.Failure as error:
|
|
||||||
result = (1,"error when destroy old backup vm %s"%(error))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
self.logger.debug("[%s] Copy %s in progress on %s",self.backup_name,vm_name,storage_name)
|
|
||||||
try:
|
|
||||||
backup_vm = session.xenapi.VM.copy(snapshot,vm_backup_name+now.strftime("%Y-%m-%d %H:%M"),storage)
|
|
||||||
except XenAPI.Failure as error:
|
|
||||||
result = (1,"error when copy %s"%(error))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
# define VM as a template
|
|
||||||
session.xenapi.VM.set_is_a_template(backup_vm,False)
|
|
||||||
|
|
||||||
#change the network of the new VM
|
|
||||||
try:
|
|
||||||
vifDestroy = session.xenapi.VM.get_VIFs(backup_vm)
|
|
||||||
except IndexError as error:
|
|
||||||
result = (1,"error get VIF opaqueref %s"%(error))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
for i in vifDestroy:
|
|
||||||
vifRecord = session.xenapi.VIF.get_record(i)
|
|
||||||
session.xenapi.VIF.destroy(i)
|
|
||||||
data = {'MAC': vifRecord['MAC'],
|
|
||||||
'MAC_autogenerated': False,
|
|
||||||
'MTU': vifRecord['MTU'],
|
|
||||||
'VM': backup_vm,
|
|
||||||
'current_operations': vifRecord['current_operations'],
|
|
||||||
'currently_attached': vifRecord['currently_attached'],
|
|
||||||
'device': vifRecord['device'],
|
|
||||||
'ipv4_allowed': vifRecord['ipv4_allowed'],
|
|
||||||
'ipv6_allowed': vifRecord['ipv6_allowed'],
|
|
||||||
'locking_mode': vifRecord['locking_mode'],
|
|
||||||
'network': networkRef,
|
|
||||||
'other_config': vifRecord['other_config'],
|
|
||||||
'qos_algorithm_params': vifRecord['qos_algorithm_params'],
|
|
||||||
'qos_algorithm_type': vifRecord['qos_algorithm_type'],
|
|
||||||
'qos_supported_algorithms': vifRecord['qos_supported_algorithms'],
|
|
||||||
'runtime_properties': vifRecord['runtime_properties'],
|
|
||||||
'status_code': vifRecord['status_code'],
|
|
||||||
'status_detail': vifRecord['status_detail']
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
session.xenapi.VIF.create(data)
|
|
||||||
except Exception as error:
|
|
||||||
result = (1,error)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
if self.start_vm in ['true', '1', 't', 'y', 'yes', 'oui']:
|
|
||||||
session.xenapi.VM.start(backup_vm,False,True)
|
|
||||||
|
|
||||||
session.xenapi.VM.set_name_description(backup_vm,"snapshot created by tisbackup on : %s"%(now.strftime("%Y-%m-%d %H:%M")))
|
|
||||||
|
|
||||||
size_backup = 0
|
|
||||||
for vbd in session.xenapi.VM.get_VBDs(backup_vm):
|
|
||||||
if session.xenapi.VBD.get_type(vbd) == 'CD' and session.xenapi.VBD.get_record(vbd)['empty'] == False:
|
|
||||||
session.xenapi.VBD.eject(vbd)
|
|
||||||
else:
|
|
||||||
vdi = session.xenapi.VBD.get_VDI(vbd)
|
|
||||||
if not 'NULL' in vdi:
|
|
||||||
size_backup = size_backup + int(session.xenapi.VDI.get_record(vdi)['physical_utilisation'])
|
|
||||||
|
|
||||||
result = (0,size_backup)
|
|
||||||
if self.delete_snapshot == 'no':
|
|
||||||
return result
|
|
||||||
|
|
||||||
#Disable automatic boot
|
|
||||||
if 'auto_poweron' in session.xenapi.VM.get_other_config(backup_vm):
|
|
||||||
session.xenapi.VM.remove_from_other_config(backup_vm, "auto_poweron")
|
|
||||||
|
|
||||||
if not str2bool(self.halt_vm):
|
|
||||||
#delete the snapshot
|
|
||||||
try:
|
|
||||||
for vbd in session.xenapi.VM.get_VBDs(snapshot):
|
|
||||||
if session.xenapi.VBD.get_type(vbd) == 'CD' and session.xenapi.VBD.get_record(vbd)['empty'] == False:
|
|
||||||
session.xenapi.VBD.eject(vbd)
|
|
||||||
else:
|
|
||||||
vdi = session.xenapi.VBD.get_VDI(vbd)
|
|
||||||
if not 'NULL' in vdi:
|
|
||||||
session.xenapi.VDI.destroy(vdi)
|
|
||||||
session.xenapi.VM.destroy(snapshot)
|
|
||||||
except XenAPI.Failure as error:
|
|
||||||
result = (1,"error when destroy snapshot %s"%(error))
|
|
||||||
return result
|
|
||||||
else:
|
|
||||||
if status_vm == "Running":
|
|
||||||
self.logger.debug("[%s] Starting in progress",self.backup_name)
|
|
||||||
if dry_run:
|
|
||||||
print("session.xenapi.VM.start(vm,False,True)")
|
|
||||||
else:
|
|
||||||
session.xenapi.VM.start(vm,False,True)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def do_backup(self,stats):
|
|
||||||
try:
|
|
||||||
timestamp = int(time.time())
|
|
||||||
cmd = self.copy_vm_to_sr(self.vm_name, self.storage_name, self.dry_run, delete_snapshot=self.delete_snapshot)
|
|
||||||
|
|
||||||
if cmd[0] == 0:
|
|
||||||
timeExec = int(time.time()) - timestamp
|
|
||||||
stats['log']='copy of %s to an other storage OK' % (self.backup_name)
|
|
||||||
stats['status']='OK'
|
|
||||||
stats['total_files_count'] = 1
|
|
||||||
stats['total_bytes'] = cmd[1]
|
|
||||||
|
|
||||||
stats['backup_location'] = self.storage_name
|
|
||||||
else:
|
|
||||||
stats['status']='ERROR'
|
|
||||||
stats['log']=cmd[1]
|
|
||||||
|
|
||||||
except BaseException as e:
|
|
||||||
stats['status']='ERROR'
|
|
||||||
stats['log']=str(e)
|
|
||||||
raise
|
|
||||||
|
|
||||||
def register_existingbackups(self):
|
|
||||||
"""scan backup dir and insert stats in database"""
|
|
||||||
#This backup is on target server, no data available on this server
|
|
||||||
pass
|
|
||||||
|
|
||||||
register_driver(copy_vm_xcp)
|
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# This file is part of TISBackup
|
||||||
|
#
|
||||||
|
# TISBackup is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# TISBackup is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with TISBackup. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
"""Database management for backup statistics and history."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from .utils import (
|
||||||
|
convert_bytes,
|
||||||
|
datetime2isodate,
|
||||||
|
hours_minutes,
|
||||||
|
html_table,
|
||||||
|
isodate2datetime,
|
||||||
|
pp,
|
||||||
|
splitThousands,
|
||||||
|
time2display,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BackupStat:
|
||||||
|
"""Manages SQLite database for backup statistics and history."""
|
||||||
|
|
||||||
|
dbpath = ""
|
||||||
|
db = None
|
||||||
|
logger = logging.getLogger("tisbackup")
|
||||||
|
|
||||||
|
def __init__(self, dbpath):
|
||||||
|
self.dbpath = dbpath
|
||||||
|
if not os.path.isfile(self.dbpath):
|
||||||
|
self.db = sqlite3.connect(self.dbpath)
|
||||||
|
self.initdb()
|
||||||
|
else:
|
||||||
|
self.db = sqlite3.connect(self.dbpath, check_same_thread=False)
|
||||||
|
if "'TYPE'" not in str(self.db.execute("select * from stats").description):
|
||||||
|
self.updatedb()
|
||||||
|
|
||||||
|
def updatedb(self):
|
||||||
|
"""Update database schema to add TYPE column if missing."""
|
||||||
|
self.logger.debug("Update stat database")
|
||||||
|
self.db.execute("alter table stats add column TYPE TEXT;")
|
||||||
|
self.db.execute("update stats set TYPE='BACKUP';")
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
def initdb(self):
|
||||||
|
"""Initialize database schema."""
|
||||||
|
assert isinstance(self.db, sqlite3.Connection)
|
||||||
|
self.logger.debug("Initialize stat database")
|
||||||
|
self.db.execute("""
|
||||||
|
create table stats (
|
||||||
|
backup_name TEXT,
|
||||||
|
server_name TEXT,
|
||||||
|
description TEXT,
|
||||||
|
backup_start TEXT,
|
||||||
|
backup_end TEXT,
|
||||||
|
backup_duration NUMERIC,
|
||||||
|
total_files_count INT,
|
||||||
|
written_files_count INT,
|
||||||
|
total_bytes INT,
|
||||||
|
written_bytes INT,
|
||||||
|
status TEXT,
|
||||||
|
log TEXT,
|
||||||
|
backup_location TEXT,
|
||||||
|
TYPE TEXT)""")
|
||||||
|
self.db.execute("""
|
||||||
|
create index idx_stats_backup_name on stats(backup_name);""")
|
||||||
|
self.db.execute("""
|
||||||
|
create index idx_stats_backup_location on stats(backup_location);""")
|
||||||
|
self.db.execute("""
|
||||||
|
CREATE INDEX idx_stats_backup_name_start on stats(backup_name,backup_start);""")
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
def start(self, backup_name, server_name, TYPE, description="", backup_location=None):
|
||||||
|
"""Add in stat DB a record for the newly running backup"""
|
||||||
|
return self.add(
|
||||||
|
backup_name=backup_name,
|
||||||
|
server_name=server_name,
|
||||||
|
description=description,
|
||||||
|
backup_start=datetime2isodate(),
|
||||||
|
status="Running",
|
||||||
|
TYPE=TYPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
def finish(
|
||||||
|
self,
|
||||||
|
rowid,
|
||||||
|
total_files_count=None,
|
||||||
|
written_files_count=None,
|
||||||
|
total_bytes=None,
|
||||||
|
written_bytes=None,
|
||||||
|
log=None,
|
||||||
|
status="OK",
|
||||||
|
backup_end=None,
|
||||||
|
backup_duration=None,
|
||||||
|
backup_location=None,
|
||||||
|
):
|
||||||
|
"""Update record in stat DB for the finished backup"""
|
||||||
|
if not backup_end:
|
||||||
|
backup_end = datetime2isodate()
|
||||||
|
if backup_duration is None:
|
||||||
|
try:
|
||||||
|
# get duration using start of backup datetime
|
||||||
|
backup_duration = (
|
||||||
|
isodate2datetime(backup_end)
|
||||||
|
- isodate2datetime(self.query("select backup_start from stats where rowid=?", (rowid,))[0]["backup_start"])
|
||||||
|
).seconds / 3600.0
|
||||||
|
except:
|
||||||
|
backup_duration = None
|
||||||
|
|
||||||
|
# update stat record
|
||||||
|
self.db.execute(
|
||||||
|
"""\
|
||||||
|
update stats set
|
||||||
|
total_files_count=?,written_files_count=?,total_bytes=?,written_bytes=?,log=?,status=?,backup_end=?,backup_duration=?,backup_location=?
|
||||||
|
where
|
||||||
|
rowid = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
total_files_count,
|
||||||
|
written_files_count,
|
||||||
|
total_bytes,
|
||||||
|
written_bytes,
|
||||||
|
log,
|
||||||
|
status,
|
||||||
|
backup_end,
|
||||||
|
backup_duration,
|
||||||
|
backup_location,
|
||||||
|
rowid,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
def add(
|
||||||
|
self,
|
||||||
|
backup_name="",
|
||||||
|
server_name="",
|
||||||
|
description="",
|
||||||
|
backup_start=None,
|
||||||
|
backup_end=None,
|
||||||
|
backup_duration=None,
|
||||||
|
total_files_count=None,
|
||||||
|
written_files_count=None,
|
||||||
|
total_bytes=None,
|
||||||
|
written_bytes=None,
|
||||||
|
status="draft",
|
||||||
|
log="",
|
||||||
|
TYPE="",
|
||||||
|
backup_location=None,
|
||||||
|
):
|
||||||
|
"""Add a new backup record to the database."""
|
||||||
|
if not backup_start:
|
||||||
|
backup_start = datetime2isodate()
|
||||||
|
if not backup_end:
|
||||||
|
backup_end = datetime2isodate()
|
||||||
|
|
||||||
|
cur = self.db.execute(
|
||||||
|
"""\
|
||||||
|
insert into stats (
|
||||||
|
backup_name,
|
||||||
|
server_name,
|
||||||
|
description,
|
||||||
|
backup_start,
|
||||||
|
backup_end,
|
||||||
|
backup_duration,
|
||||||
|
total_files_count,
|
||||||
|
written_files_count,
|
||||||
|
total_bytes,
|
||||||
|
written_bytes,
|
||||||
|
status,
|
||||||
|
log,
|
||||||
|
backup_location,
|
||||||
|
TYPE) values (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
backup_name,
|
||||||
|
server_name,
|
||||||
|
description,
|
||||||
|
backup_start,
|
||||||
|
backup_end,
|
||||||
|
backup_duration,
|
||||||
|
total_files_count,
|
||||||
|
written_files_count,
|
||||||
|
total_bytes,
|
||||||
|
written_bytes,
|
||||||
|
status,
|
||||||
|
log,
|
||||||
|
backup_location,
|
||||||
|
TYPE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
return cur.lastrowid
|
||||||
|
|
||||||
|
def query(self, query, args=(), one=False):
|
||||||
|
"""
|
||||||
|
execute la requete query sur la db et renvoie un tableau de dictionnaires
|
||||||
|
"""
|
||||||
|
cur = self.db.execute(query, args)
|
||||||
|
rv = [dict((cur.description[idx][0], value) for idx, value in enumerate(row)) for row in cur.fetchall()]
|
||||||
|
return (rv[0] if rv else None) if one else rv
|
||||||
|
|
||||||
|
def last_backups(self, backup_name, count=30):
|
||||||
|
"""Display last N backups for a given backup_name."""
|
||||||
|
if backup_name:
|
||||||
|
cur = self.db.execute("select * from stats where backup_name=? order by backup_end desc limit ?", (backup_name, count))
|
||||||
|
else:
|
||||||
|
cur = self.db.execute("select * from stats order by backup_end desc limit ?", (count,))
|
||||||
|
|
||||||
|
def fcb(fieldname, value):
|
||||||
|
if fieldname in ("backup_start", "backup_end"):
|
||||||
|
return time2display(isodate2datetime(value))
|
||||||
|
elif "bytes" in fieldname:
|
||||||
|
return convert_bytes(value)
|
||||||
|
elif "count" in fieldname:
|
||||||
|
return splitThousands(value, " ", ".")
|
||||||
|
elif "backup_duration" in fieldname:
|
||||||
|
return hours_minutes(value)
|
||||||
|
else:
|
||||||
|
return value
|
||||||
|
|
||||||
|
# for r in self.query('select * from stats where backup_name=? order by backup_end desc limit ?',(backup_name,count)):
|
||||||
|
print((pp(cur, None, 1, fcb)))
|
||||||
|
|
||||||
|
def fcb(self, fields, fieldname, value):
|
||||||
|
"""Format callback for HTML table display."""
|
||||||
|
if fieldname in ("backup_start", "backup_end"):
|
||||||
|
return time2display(isodate2datetime(value))
|
||||||
|
elif "bytes" in fieldname:
|
||||||
|
return convert_bytes(value)
|
||||||
|
elif "count" in fieldname:
|
||||||
|
return splitThousands(value, " ", ".")
|
||||||
|
elif "backup_duration" in fieldname:
|
||||||
|
return hours_minutes(value)
|
||||||
|
else:
|
||||||
|
return value
|
||||||
|
|
||||||
|
def as_html(self, cur):
|
||||||
|
"""Convert cursor to HTML table."""
|
||||||
|
if cur:
|
||||||
|
return html_table(cur, self.fcb)
|
||||||
|
else:
|
||||||
|
return html_table(self.db.execute("select * from stats order by backup_start asc"), self.fcb)
|
||||||
@@ -55,15 +55,17 @@
|
|||||||
# --------------------------------------------------------------------
|
# --------------------------------------------------------------------
|
||||||
|
|
||||||
import gettext
|
import gettext
|
||||||
import six.moves.xmlrpc_client as xmlrpclib
|
|
||||||
import six.moves.http_client as httplib
|
|
||||||
import socket
|
import socket
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
translation = gettext.translation('xen-xm', fallback = True)
|
import six.moves.http_client as httplib
|
||||||
|
import six.moves.xmlrpc_client as xmlrpclib
|
||||||
|
|
||||||
|
translation = gettext.translation("xen-xm", fallback=True)
|
||||||
|
|
||||||
|
API_VERSION_1_1 = "1.1"
|
||||||
|
API_VERSION_1_2 = "1.2"
|
||||||
|
|
||||||
API_VERSION_1_1 = '1.1'
|
|
||||||
API_VERSION_1_2 = '1.2'
|
|
||||||
|
|
||||||
class Failure(Exception):
|
class Failure(Exception):
|
||||||
def __init__(self, details):
|
def __init__(self, details):
|
||||||
@@ -78,41 +80,48 @@ class Failure(Exception):
|
|||||||
return msg
|
return msg
|
||||||
|
|
||||||
def _details_map(self):
|
def _details_map(self):
|
||||||
return dict([(str(i), self.details[i])
|
return dict([(str(i), self.details[i]) for i in range(len(self.details))])
|
||||||
for i in range(len(self.details))])
|
|
||||||
|
|
||||||
|
|
||||||
# Just a "constant" that we use to decide whether to retry the RPC
|
# Just a "constant" that we use to decide whether to retry the RPC
|
||||||
_RECONNECT_AND_RETRY = object()
|
_RECONNECT_AND_RETRY = object()
|
||||||
|
|
||||||
|
|
||||||
class UDSHTTPConnection(httplib.HTTPConnection):
|
class UDSHTTPConnection(httplib.HTTPConnection):
|
||||||
"""HTTPConnection subclass to allow HTTP over Unix domain sockets. """
|
"""HTTPConnection subclass to allow HTTP over Unix domain sockets."""
|
||||||
|
|
||||||
def connect(self):
|
def connect(self):
|
||||||
path = self.host.replace("_", "/")
|
path = self.host.replace("_", "/")
|
||||||
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
self.sock.connect(path)
|
self.sock.connect(path)
|
||||||
|
|
||||||
|
|
||||||
class UDSHTTP(httplib.HTTPConnection):
|
class UDSHTTP(httplib.HTTPConnection):
|
||||||
_connection_class = UDSHTTPConnection
|
_connection_class = UDSHTTPConnection
|
||||||
|
|
||||||
|
|
||||||
class UDSTransport(xmlrpclib.Transport):
|
class UDSTransport(xmlrpclib.Transport):
|
||||||
def __init__(self, use_datetime=0):
|
def __init__(self, use_datetime=0):
|
||||||
self._use_datetime = use_datetime
|
self._use_datetime = use_datetime
|
||||||
self._extra_headers=[]
|
self._extra_headers = []
|
||||||
self._connection = (None, None)
|
self._connection = (None, None)
|
||||||
|
|
||||||
def add_extra_header(self, key, value):
|
def add_extra_header(self, key, value):
|
||||||
self._extra_headers += [ (key,value) ]
|
self._extra_headers += [(key, value)]
|
||||||
|
|
||||||
def make_connection(self, host):
|
def make_connection(self, host):
|
||||||
# Python 2.4 compatibility
|
# Python 2.4 compatibility
|
||||||
if sys.version_info[0] <= 2 and sys.version_info[1] < 7:
|
if sys.version_info[0] <= 2 and sys.version_info[1] < 7:
|
||||||
return UDSHTTP(host)
|
return UDSHTTP(host)
|
||||||
else:
|
else:
|
||||||
return UDSHTTPConnection(host)
|
return UDSHTTPConnection(host)
|
||||||
|
|
||||||
def send_request(self, connection, handler, request_body):
|
def send_request(self, connection, handler, request_body):
|
||||||
connection.putrequest("POST", handler)
|
connection.putrequest("POST", handler)
|
||||||
for key, value in self._extra_headers:
|
for key, value in self._extra_headers:
|
||||||
connection.putheader(key, value)
|
connection.putheader(key, value)
|
||||||
|
|
||||||
|
|
||||||
class Session(xmlrpclib.ServerProxy):
|
class Session(xmlrpclib.ServerProxy):
|
||||||
"""A server proxy and session manager for communicating with xapi using
|
"""A server proxy and session manager for communicating with xapi using
|
||||||
the Xen-API.
|
the Xen-API.
|
||||||
@@ -125,32 +134,27 @@ class Session(xmlrpclib.ServerProxy):
|
|||||||
session.xenapi.session.logout()
|
session.xenapi.session.logout()
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, uri, transport=None, encoding=None, verbose=0,
|
def __init__(self, uri, transport=None, encoding=None, verbose=0, allow_none=1, ignore_ssl=False):
|
||||||
allow_none=1, ignore_ssl=False):
|
|
||||||
|
|
||||||
# Fix for CA-172901 (+ Python 2.4 compatibility)
|
# Fix for CA-172901 (+ Python 2.4 compatibility)
|
||||||
# Fix for context=ctx ( < Python 2.7.9 compatibility)
|
# Fix for context=ctx ( < Python 2.7.9 compatibility)
|
||||||
if not (sys.version_info[0] <= 2 and sys.version_info[1] <= 7 and sys.version_info[2] <= 9 ) \
|
if not (sys.version_info[0] <= 2 and sys.version_info[1] <= 7 and sys.version_info[2] <= 9) and ignore_ssl:
|
||||||
and ignore_ssl:
|
|
||||||
import ssl
|
import ssl
|
||||||
|
|
||||||
ctx = ssl._create_unverified_context()
|
ctx = ssl._create_unverified_context()
|
||||||
xmlrpclib.ServerProxy.__init__(self, uri, transport, encoding,
|
xmlrpclib.ServerProxy.__init__(self, uri, transport, encoding, verbose, allow_none, context=ctx)
|
||||||
verbose, allow_none, context=ctx)
|
|
||||||
else:
|
else:
|
||||||
xmlrpclib.ServerProxy.__init__(self, uri, transport, encoding,
|
xmlrpclib.ServerProxy.__init__(self, uri, transport, encoding, verbose, allow_none)
|
||||||
verbose, allow_none)
|
|
||||||
self.transport = transport
|
self.transport = transport
|
||||||
self._session = None
|
self._session = None
|
||||||
self.last_login_method = None
|
self.last_login_method = None
|
||||||
self.last_login_params = None
|
self.last_login_params = None
|
||||||
self.API_version = API_VERSION_1_1
|
self.API_version = API_VERSION_1_1
|
||||||
|
|
||||||
|
|
||||||
def xenapi_request(self, methodname, params):
|
def xenapi_request(self, methodname, params):
|
||||||
if methodname.startswith('login'):
|
if methodname.startswith("login"):
|
||||||
self._login(methodname, params)
|
self._login(methodname, params)
|
||||||
return None
|
return None
|
||||||
elif methodname == 'logout' or methodname == 'session.logout':
|
elif methodname == "logout" or methodname == "session.logout":
|
||||||
self._logout()
|
self._logout()
|
||||||
return None
|
return None
|
||||||
else:
|
else:
|
||||||
@@ -161,29 +165,25 @@ class Session(xmlrpclib.ServerProxy):
|
|||||||
if result is _RECONNECT_AND_RETRY:
|
if result is _RECONNECT_AND_RETRY:
|
||||||
retry_count += 1
|
retry_count += 1
|
||||||
if self.last_login_method:
|
if self.last_login_method:
|
||||||
self._login(self.last_login_method,
|
self._login(self.last_login_method, self.last_login_params)
|
||||||
self.last_login_params)
|
|
||||||
else:
|
else:
|
||||||
raise xmlrpclib.Fault(401, 'You must log in')
|
raise xmlrpclib.Fault(401, "You must log in")
|
||||||
else:
|
else:
|
||||||
return result
|
return result
|
||||||
raise xmlrpclib.Fault(
|
raise xmlrpclib.Fault(500, "Tried 3 times to get a valid session, but failed")
|
||||||
500, 'Tried 3 times to get a valid session, but failed')
|
|
||||||
|
|
||||||
def _login(self, method, params):
|
def _login(self, method, params):
|
||||||
try:
|
try:
|
||||||
result = _parse_result(
|
result = _parse_result(getattr(self, "session.%s" % method)(*params))
|
||||||
getattr(self, 'session.%s' % method)(*params))
|
|
||||||
if result is _RECONNECT_AND_RETRY:
|
if result is _RECONNECT_AND_RETRY:
|
||||||
raise xmlrpclib.Fault(
|
raise xmlrpclib.Fault(500, "Received SESSION_INVALID when logging in")
|
||||||
500, 'Received SESSION_INVALID when logging in')
|
|
||||||
self._session = result
|
self._session = result
|
||||||
self.last_login_method = method
|
self.last_login_method = method
|
||||||
self.last_login_params = params
|
self.last_login_params = params
|
||||||
self.API_version = self._get_api_version()
|
self.API_version = self._get_api_version()
|
||||||
except socket.error as e:
|
except socket.error as e:
|
||||||
if e.errno == socket.errno.ETIMEDOUT:
|
if e.errno == socket.errno.ETIMEDOUT:
|
||||||
raise xmlrpclib.Fault(504, 'The connection timed out')
|
raise xmlrpclib.Fault(504, "The connection timed out")
|
||||||
else:
|
else:
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
@@ -204,41 +204,41 @@ class Session(xmlrpclib.ServerProxy):
|
|||||||
host = self.xenapi.pool.get_master(pool)
|
host = self.xenapi.pool.get_master(pool)
|
||||||
major = self.xenapi.host.get_API_version_major(host)
|
major = self.xenapi.host.get_API_version_major(host)
|
||||||
minor = self.xenapi.host.get_API_version_minor(host)
|
minor = self.xenapi.host.get_API_version_minor(host)
|
||||||
return "%s.%s"%(major,minor)
|
return "%s.%s" % (major, minor)
|
||||||
|
|
||||||
def __getattr__(self, name):
|
def __getattr__(self, name):
|
||||||
if name == 'handle':
|
if name == "handle":
|
||||||
return self._session
|
return self._session
|
||||||
elif name == 'xenapi':
|
elif name == "xenapi":
|
||||||
return _Dispatcher(self.API_version, self.xenapi_request, None)
|
return _Dispatcher(self.API_version, self.xenapi_request, None)
|
||||||
elif name.startswith('login') or name.startswith('slave_local'):
|
elif name.startswith("login") or name.startswith("slave_local"):
|
||||||
return lambda *params: self._login(name, params)
|
return lambda *params: self._login(name, params)
|
||||||
elif name == 'logout':
|
elif name == "logout":
|
||||||
return _Dispatcher(self.API_version, self.xenapi_request, "logout")
|
return _Dispatcher(self.API_version, self.xenapi_request, "logout")
|
||||||
else:
|
else:
|
||||||
return xmlrpclib.ServerProxy.__getattr__(self, name)
|
return xmlrpclib.ServerProxy.__getattr__(self, name)
|
||||||
|
|
||||||
|
|
||||||
def xapi_local():
|
def xapi_local():
|
||||||
return Session("http://_var_lib_xcp_xapi/", transport=UDSTransport())
|
return Session("http://_var_lib_xcp_xapi/", transport=UDSTransport())
|
||||||
|
|
||||||
|
|
||||||
def _parse_result(result):
|
def _parse_result(result):
|
||||||
if type(result) != dict or 'Status' not in result:
|
if not isinstance(type(result), dict) or "Status" not in result:
|
||||||
raise xmlrpclib.Fault(500, 'Missing Status in response from server' + result)
|
raise xmlrpclib.Fault(500, "Missing Status in response from server" + result)
|
||||||
if result['Status'] == 'Success':
|
if result["Status"] == "Success":
|
||||||
if 'Value' in result:
|
if "Value" in result:
|
||||||
return result['Value']
|
return result["Value"]
|
||||||
else:
|
else:
|
||||||
raise xmlrpclib.Fault(500,
|
raise xmlrpclib.Fault(500, "Missing Value in response from server")
|
||||||
'Missing Value in response from server')
|
|
||||||
else:
|
else:
|
||||||
if 'ErrorDescription' in result:
|
if "ErrorDescription" in result:
|
||||||
if result['ErrorDescription'][0] == 'SESSION_INVALID':
|
if result["ErrorDescription"][0] == "SESSION_INVALID":
|
||||||
return _RECONNECT_AND_RETRY
|
return _RECONNECT_AND_RETRY
|
||||||
else:
|
else:
|
||||||
raise Failure(result['ErrorDescription'])
|
raise Failure(result["ErrorDescription"])
|
||||||
else:
|
else:
|
||||||
raise xmlrpclib.Fault(
|
raise xmlrpclib.Fault(500, "Missing ErrorDescription in response from server")
|
||||||
500, 'Missing ErrorDescription in response from server')
|
|
||||||
|
|
||||||
|
|
||||||
# Based upon _Method from xmlrpclib.
|
# Based upon _Method from xmlrpclib.
|
||||||
@@ -250,9 +250,9 @@ class _Dispatcher:
|
|||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
if self.__name:
|
if self.__name:
|
||||||
return '<XenAPI._Dispatcher for %s>' % self.__name
|
return "<XenAPI._Dispatcher for %s>" % self.__name
|
||||||
else:
|
else:
|
||||||
return '<XenAPI._Dispatcher>'
|
return "<XenAPI._Dispatcher>"
|
||||||
|
|
||||||
def __getattr__(self, name):
|
def __getattr__(self, name):
|
||||||
if self.__name is None:
|
if self.__name is None:
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# This file is part of TISBackup
|
||||||
|
#
|
||||||
|
# TISBackup is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# TISBackup is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with TISBackup. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
"""
|
||||||
|
TISBackup drivers - Pluggable backup driver implementations.
|
||||||
|
|
||||||
|
This package contains all backup driver implementations:
|
||||||
|
- Database drivers (MySQL, PostgreSQL, Oracle, SQL Server)
|
||||||
|
- File sync drivers (rsync, rsync+btrfs)
|
||||||
|
- VM backup drivers (XenServer XVA, VMware VMDK)
|
||||||
|
- Other drivers (Samba4, network switches, etc.)
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Import all drivers to ensure they register themselves
|
||||||
|
from .backup_mysql import backup_mysql
|
||||||
|
from .backup_null import backup_null
|
||||||
|
from .backup_oracle import backup_oracle
|
||||||
|
from .backup_pgsql import backup_pgsql
|
||||||
|
from .backup_rsync import backup_rsync, backup_rsync_ssh
|
||||||
|
from .backup_rsync_btrfs import backup_rsync_btrfs, backup_rsync__btrfs_ssh
|
||||||
|
from .backup_samba4 import backup_samba4
|
||||||
|
from .backup_sqlserver import backup_sqlserver
|
||||||
|
from .backup_switch import backup_switch
|
||||||
|
from .backup_vmdk import backup_vmdk
|
||||||
|
from .backup_xcp_metadata import backup_xcp_metadata
|
||||||
|
from .backup_xva import backup_xva
|
||||||
|
from .copy_vm_xcp import copy_vm_xcp
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"backup_mysql",
|
||||||
|
"backup_null",
|
||||||
|
"backup_oracle",
|
||||||
|
"backup_pgsql",
|
||||||
|
"backup_rsync",
|
||||||
|
"backup_rsync_ssh",
|
||||||
|
"backup_rsync_btrfs",
|
||||||
|
"backup_rsync__btrfs_ssh",
|
||||||
|
"backup_samba4",
|
||||||
|
"backup_sqlserver",
|
||||||
|
"backup_switch",
|
||||||
|
"backup_vmdk",
|
||||||
|
"backup_xcp_metadata",
|
||||||
|
"backup_xva",
|
||||||
|
"copy_vm_xcp",
|
||||||
|
]
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# This file is part of TISBackup
|
||||||
|
#
|
||||||
|
# TISBackup is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# TISBackup is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with TISBackup. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
sys.stderr = open("/dev/null") # Silence silly warnings from paramiko
|
||||||
|
import paramiko
|
||||||
|
except ImportError as e:
|
||||||
|
print(("Error : can not load paramiko library %s" % e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
sys.stderr = sys.__stderr__
|
||||||
|
|
||||||
|
from libtisbackup import *
|
||||||
|
|
||||||
|
|
||||||
|
class backup_mysql(backup_generic):
|
||||||
|
"""Backup a mysql database as gzipped sql file through ssh"""
|
||||||
|
|
||||||
|
type = "mysql+ssh"
|
||||||
|
required_params = backup_generic.required_params + ["db_user", "db_passwd", "private_key"]
|
||||||
|
optional_params = backup_generic.optional_params + ["db_name"]
|
||||||
|
|
||||||
|
db_name = ""
|
||||||
|
db_user = ""
|
||||||
|
db_passwd = ""
|
||||||
|
|
||||||
|
dest_dir = ""
|
||||||
|
|
||||||
|
def do_backup(self, stats):
|
||||||
|
self.dest_dir = os.path.join(self.backup_dir, self.backup_start_date)
|
||||||
|
|
||||||
|
if not os.path.isdir(self.dest_dir):
|
||||||
|
if not self.dry_run:
|
||||||
|
os.makedirs(self.dest_dir)
|
||||||
|
else:
|
||||||
|
print(('mkdir "%s"' % self.dest_dir))
|
||||||
|
else:
|
||||||
|
raise Exception("backup destination directory already exists : %s" % self.dest_dir)
|
||||||
|
|
||||||
|
self.logger.debug("[%s] Connecting to %s with user root and key %s", self.backup_name, self.server_name, self.private_key)
|
||||||
|
mykey = load_ssh_private_key(self.private_key)
|
||||||
|
|
||||||
|
self.ssh = paramiko.SSHClient()
|
||||||
|
self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
self.ssh.connect(self.server_name, username="root", pkey=mykey, port=self.ssh_port)
|
||||||
|
|
||||||
|
self.db_passwd = self.db_passwd.replace("$", r"\$")
|
||||||
|
if not self.db_name:
|
||||||
|
stats["log"] = "Successfully backuping processed to the following databases :"
|
||||||
|
stats["status"] = "List"
|
||||||
|
cmd = 'mysql -N -B -p -e "SHOW DATABASES;" -u ' + self.db_user + " -p" + self.db_passwd + " 2> /dev/null"
|
||||||
|
self.logger.debug("[%s] List databases: %s", self.backup_name, cmd)
|
||||||
|
(error_code, output) = ssh_exec(cmd, ssh=self.ssh)
|
||||||
|
self.logger.debug("[%s] Output of %s :\n%s", self.backup_name, cmd, output)
|
||||||
|
if error_code:
|
||||||
|
raise Exception('Aborting, Not null exit code (%i) for "%s"' % (error_code, cmd))
|
||||||
|
databases = output.split("\n")
|
||||||
|
for database in databases:
|
||||||
|
if database != "":
|
||||||
|
self.db_name = database.rstrip()
|
||||||
|
self.do_mysqldump(stats)
|
||||||
|
|
||||||
|
else:
|
||||||
|
stats["log"] = "Successfully backup processed to the following database :"
|
||||||
|
self.do_mysqldump(stats)
|
||||||
|
|
||||||
|
def do_mysqldump(self, stats):
|
||||||
|
t = datetime.datetime.now()
|
||||||
|
backup_start_date = t.strftime("%Y%m%d-%Hh%Mm%S")
|
||||||
|
|
||||||
|
# dump db
|
||||||
|
stats["status"] = "Dumping"
|
||||||
|
cmd = (
|
||||||
|
"mysqldump --single-transaction -u"
|
||||||
|
+ self.db_user
|
||||||
|
+ " -p"
|
||||||
|
+ self.db_passwd
|
||||||
|
+ " "
|
||||||
|
+ self.db_name
|
||||||
|
+ " > /tmp/"
|
||||||
|
+ self.db_name
|
||||||
|
+ "-"
|
||||||
|
+ backup_start_date
|
||||||
|
+ ".sql"
|
||||||
|
)
|
||||||
|
self.logger.debug("[%s] Dump DB : %s", self.backup_name, cmd)
|
||||||
|
if not self.dry_run:
|
||||||
|
(error_code, output) = ssh_exec(cmd, ssh=self.ssh)
|
||||||
|
print(output)
|
||||||
|
self.logger.debug("[%s] Output of %s :\n%s", self.backup_name, cmd, output)
|
||||||
|
if error_code:
|
||||||
|
raise Exception('Aborting, Not null exit code (%i) for "%s"' % (error_code, cmd))
|
||||||
|
|
||||||
|
# zip the file
|
||||||
|
stats["status"] = "Zipping"
|
||||||
|
cmd = "gzip /tmp/" + self.db_name + "-" + backup_start_date + ".sql"
|
||||||
|
self.logger.debug("[%s] Compress backup : %s", self.backup_name, cmd)
|
||||||
|
if not self.dry_run:
|
||||||
|
(error_code, output) = ssh_exec(cmd, ssh=self.ssh)
|
||||||
|
self.logger.debug("[%s] Output of %s :\n%s", self.backup_name, cmd, output)
|
||||||
|
if error_code:
|
||||||
|
raise Exception('Aborting, Not null exit code (%i) for "%s"' % (error_code, cmd))
|
||||||
|
|
||||||
|
# get the file
|
||||||
|
stats["status"] = "SFTP"
|
||||||
|
filepath = "/tmp/" + self.db_name + "-" + backup_start_date + ".sql.gz"
|
||||||
|
localpath = os.path.join(self.dest_dir, self.db_name + ".sql.gz")
|
||||||
|
self.logger.debug("[%s] Get gz backup with sftp on %s from %s to %s", self.backup_name, self.server_name, filepath, localpath)
|
||||||
|
if not self.dry_run:
|
||||||
|
transport = self.ssh.get_transport()
|
||||||
|
sftp = paramiko.SFTPClient.from_transport(transport)
|
||||||
|
sftp.get(filepath, localpath)
|
||||||
|
sftp.close()
|
||||||
|
|
||||||
|
if not self.dry_run:
|
||||||
|
stats["total_files_count"] = 1 + stats.get("total_files_count", 0)
|
||||||
|
stats["written_files_count"] = 1 + stats.get("written_files_count", 0)
|
||||||
|
stats["total_bytes"] = os.stat(localpath).st_size + stats.get("total_bytes", 0)
|
||||||
|
stats["written_bytes"] = os.stat(localpath).st_size + stats.get("written_bytes", 0)
|
||||||
|
stats["log"] = '%s "%s"' % (stats["log"], self.db_name)
|
||||||
|
stats["backup_location"] = self.dest_dir
|
||||||
|
|
||||||
|
stats["status"] = "RMTemp"
|
||||||
|
cmd = "rm -f /tmp/" + self.db_name + "-" + backup_start_date + ".sql.gz"
|
||||||
|
self.logger.debug("[%s] Remove temp gzip : %s", self.backup_name, cmd)
|
||||||
|
if not self.dry_run:
|
||||||
|
(error_code, output) = ssh_exec(cmd, ssh=self.ssh)
|
||||||
|
self.logger.debug("[%s] Output of %s :\n%s", self.backup_name, cmd, output)
|
||||||
|
if error_code:
|
||||||
|
raise Exception('Aborting, Not null exit code (%i) for "%s"' % (error_code, cmd))
|
||||||
|
stats["status"] = "OK"
|
||||||
|
|
||||||
|
def register_existingbackups(self):
|
||||||
|
"""scan backup dir and insert stats in database"""
|
||||||
|
|
||||||
|
registered = [
|
||||||
|
b["backup_location"]
|
||||||
|
for b in self.dbstat.query("select distinct backup_location from stats where backup_name=?", (self.backup_name,))
|
||||||
|
]
|
||||||
|
|
||||||
|
filelist = os.listdir(self.backup_dir)
|
||||||
|
filelist.sort()
|
||||||
|
p = re.compile(r"^\d{8,8}-\d{2,2}h\d{2,2}m\d{2,2}$")
|
||||||
|
for item in filelist:
|
||||||
|
if p.match(item):
|
||||||
|
dir_name = os.path.join(self.backup_dir, item)
|
||||||
|
if dir_name not in registered:
|
||||||
|
start = datetime.datetime.strptime(item, "%Y%m%d-%Hh%Mm%S").isoformat()
|
||||||
|
if fileisodate(dir_name) > start:
|
||||||
|
stop = fileisodate(dir_name)
|
||||||
|
else:
|
||||||
|
stop = start
|
||||||
|
self.logger.info("Registering %s started on %s", dir_name, start)
|
||||||
|
self.logger.debug(" Disk usage %s", 'du -sb "%s"' % dir_name)
|
||||||
|
if not self.dry_run:
|
||||||
|
size_bytes = int(os.popen('du -sb "%s"' % dir_name).read().split("\t")[0])
|
||||||
|
else:
|
||||||
|
size_bytes = 0
|
||||||
|
self.logger.debug(" Size in bytes : %i", size_bytes)
|
||||||
|
if not self.dry_run:
|
||||||
|
self.dbstat.add(
|
||||||
|
self.backup_name,
|
||||||
|
self.server_name,
|
||||||
|
"",
|
||||||
|
backup_start=start,
|
||||||
|
backup_end=stop,
|
||||||
|
status="OK",
|
||||||
|
total_bytes=size_bytes,
|
||||||
|
backup_location=dir_name,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.logger.info("Skipping %s, already registered", dir_name)
|
||||||
|
|
||||||
|
|
||||||
|
register_driver(backup_mysql)
|
||||||
Executable → Regular
+17
-10
@@ -18,34 +18,41 @@
|
|||||||
#
|
#
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
import os
|
|
||||||
import datetime
|
import datetime
|
||||||
from .common import *
|
import os
|
||||||
|
|
||||||
|
from libtisbackup import *
|
||||||
|
|
||||||
|
|
||||||
class backup_null(backup_generic):
|
class backup_null(backup_generic):
|
||||||
"""Null backup to register servers which don't need any backups
|
"""Null backup to register servers which don't need any backups
|
||||||
but we still want to know they are taken in account"""
|
but we still want to know they are taken in account"""
|
||||||
type = 'null'
|
|
||||||
|
|
||||||
required_params = ['type','server_name','backup_name']
|
type = "null"
|
||||||
|
|
||||||
|
required_params = ["type", "server_name", "backup_name"]
|
||||||
optional_params = []
|
optional_params = []
|
||||||
|
|
||||||
def do_backup(self,stats):
|
def do_backup(self, stats):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def process_backup(self):
|
def process_backup(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def cleanup_backup(self):
|
def cleanup_backup(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def register_existingbackups(self):
|
def register_existingbackups(self):
|
||||||
pass
|
pass
|
||||||
def export_latestbackup(self,destdir):
|
|
||||||
|
def export_latestbackup(self, destdir):
|
||||||
return {}
|
return {}
|
||||||
def checknagios(self,maxage_hours=30):
|
|
||||||
return (nagiosStateOk,"No backups needs to be performed")
|
def checknagios(self, maxage_hours=30):
|
||||||
|
return (nagiosStateOk, "No backups needs to be performed")
|
||||||
|
|
||||||
|
|
||||||
register_driver(backup_null)
|
register_driver(backup_null)
|
||||||
|
|
||||||
if __name__=='__main__':
|
if __name__ == "__main__":
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
#!/usr/bin/python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# This file is part of TISBackup
|
||||||
|
#
|
||||||
|
# TISBackup is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# TISBackup is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with TISBackup. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
sys.stderr = open("/dev/null") # Silence silly warnings from paramiko
|
||||||
|
import paramiko
|
||||||
|
except ImportError as e:
|
||||||
|
print(("Error : can not load paramiko library %s" % e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
sys.stderr = sys.__stderr__
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
from libtisbackup import *
|
||||||
|
|
||||||
|
|
||||||
|
class backup_oracle(backup_generic):
|
||||||
|
"""Backup a oracle database as zipped file through ssh"""
|
||||||
|
|
||||||
|
type = "oracle+ssh"
|
||||||
|
required_params = backup_generic.required_params + ["db_name", "private_key", "userid"]
|
||||||
|
optional_params = ["username", "remote_backup_dir", "ignore_error_oracle_code"]
|
||||||
|
db_name = ""
|
||||||
|
username = "oracle"
|
||||||
|
remote_backup_dir = r"/home/oracle/backup"
|
||||||
|
ignore_error_oracle_code = []
|
||||||
|
|
||||||
|
def do_backup(self, stats):
|
||||||
|
self.logger.debug(
|
||||||
|
"[%s] Connecting to %s with user %s and key %s", self.backup_name, self.server_name, self.username, self.private_key
|
||||||
|
)
|
||||||
|
mykey = load_ssh_private_key(self.private_key)
|
||||||
|
|
||||||
|
self.ssh = paramiko.SSHClient()
|
||||||
|
self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
self.ssh.connect(self.server_name, username=self.username, pkey=mykey, port=self.ssh_port)
|
||||||
|
|
||||||
|
t = datetime.datetime.now()
|
||||||
|
self.backup_start_date = t.strftime("%Y%m%d-%Hh%Mm%S")
|
||||||
|
dumpfile = self.remote_backup_dir + "/" + self.db_name + "_" + self.backup_start_date + ".dmp"
|
||||||
|
dumplog = self.remote_backup_dir + "/" + self.db_name + "_" + self.backup_start_date + ".log"
|
||||||
|
|
||||||
|
self.dest_dir = os.path.join(self.backup_dir, self.backup_start_date)
|
||||||
|
if not os.path.isdir(self.dest_dir):
|
||||||
|
if not self.dry_run:
|
||||||
|
os.makedirs(self.dest_dir)
|
||||||
|
else:
|
||||||
|
print(('mkdir "%s"' % self.dest_dir))
|
||||||
|
else:
|
||||||
|
raise Exception("backup destination directory already exists : %s" % self.dest_dir)
|
||||||
|
# dump db
|
||||||
|
stats["status"] = "Dumping"
|
||||||
|
cmd = "exp '%s' file='%s' grants=y log='%s'" % (self.userid, dumpfile, dumplog)
|
||||||
|
self.logger.debug("[%s] Dump DB : %s", self.backup_name, cmd)
|
||||||
|
if not self.dry_run:
|
||||||
|
(error_code, output) = ssh_exec(cmd, ssh=self.ssh)
|
||||||
|
self.logger.debug("[%s] Output of %s :\n%s", self.backup_name, cmd, output)
|
||||||
|
if error_code:
|
||||||
|
localpath = os.path.join(self.dest_dir, self.db_name + ".log")
|
||||||
|
self.logger.debug("[%s] Get log file with sftp on %s from %s to %s", self.backup_name, self.server_name, dumplog, localpath)
|
||||||
|
transport = self.ssh.get_transport()
|
||||||
|
sftp = paramiko.SFTPClient.from_transport(transport)
|
||||||
|
sftp.get(dumplog, localpath)
|
||||||
|
sftp.close()
|
||||||
|
|
||||||
|
file = open(localpath)
|
||||||
|
for line in file:
|
||||||
|
if (
|
||||||
|
re.search("EXP-[0-9]+:", line)
|
||||||
|
and re.match("EXP-[0-9]+:", line).group(0).replace(":", "") not in self.ignore_error_oracle_code
|
||||||
|
):
|
||||||
|
stats["status"] = "RMTemp"
|
||||||
|
self.clean_dumpfiles(dumpfile, dumplog)
|
||||||
|
raise Exception(
|
||||||
|
'Aborting, Not null exit code (%s) for "%s"' % (re.match("EXP-[0-9]+:", line).group(0).replace(":", ""), cmd)
|
||||||
|
)
|
||||||
|
file.close()
|
||||||
|
|
||||||
|
# zip the file
|
||||||
|
stats["status"] = "Zipping"
|
||||||
|
cmd = "gzip %s" % dumpfile
|
||||||
|
self.logger.debug("[%s] Compress backup : %s", self.backup_name, cmd)
|
||||||
|
if not self.dry_run:
|
||||||
|
(error_code, output) = ssh_exec(cmd, ssh=self.ssh)
|
||||||
|
self.logger.debug("[%s] Output of %s :\n%s", self.backup_name, cmd, output)
|
||||||
|
if error_code:
|
||||||
|
raise Exception('Aborting, Not null exit code (%i) for "%s"' % (error_code, cmd))
|
||||||
|
|
||||||
|
# get the file
|
||||||
|
stats["status"] = "SFTP"
|
||||||
|
filepath = dumpfile + ".gz"
|
||||||
|
localpath = os.path.join(self.dest_dir, self.db_name + ".dmp.gz")
|
||||||
|
self.logger.debug("[%s] Get gz backup with sftp on %s from %s to %s", self.backup_name, self.server_name, filepath, localpath)
|
||||||
|
if not self.dry_run:
|
||||||
|
transport = self.ssh.get_transport()
|
||||||
|
sftp = paramiko.SFTPClient.from_transport(transport)
|
||||||
|
sftp.get(filepath, localpath)
|
||||||
|
sftp.close()
|
||||||
|
|
||||||
|
if not self.dry_run:
|
||||||
|
stats["total_files_count"] = 1
|
||||||
|
stats["written_files_count"] = 1
|
||||||
|
stats["total_bytes"] = os.stat(localpath).st_size
|
||||||
|
stats["written_bytes"] = os.stat(localpath).st_size
|
||||||
|
stats["log"] = "gzip dump of DB %s:%s (%d bytes) to %s" % (self.server_name, self.db_name, stats["written_bytes"], localpath)
|
||||||
|
stats["backup_location"] = self.dest_dir
|
||||||
|
stats["status"] = "RMTemp"
|
||||||
|
self.clean_dumpfiles(dumpfile, dumplog)
|
||||||
|
stats["status"] = "OK"
|
||||||
|
|
||||||
|
def register_existingbackups(self):
|
||||||
|
"""scan backup dir and insert stats in database"""
|
||||||
|
|
||||||
|
registered = [
|
||||||
|
b["backup_location"]
|
||||||
|
for b in self.dbstat.query("select distinct backup_location from stats where backup_name=?", (self.backup_name,))
|
||||||
|
]
|
||||||
|
|
||||||
|
filelist = os.listdir(self.backup_dir)
|
||||||
|
filelist.sort()
|
||||||
|
p = re.compile(r"^\d{8,8}-\d{2,2}h\d{2,2}m\d{2,2}$")
|
||||||
|
for item in filelist:
|
||||||
|
if p.match(item):
|
||||||
|
dir_name = os.path.join(self.backup_dir, item)
|
||||||
|
if dir_name not in registered:
|
||||||
|
start = datetime.datetime.strptime(item, "%Y%m%d-%Hh%Mm%S").isoformat()
|
||||||
|
if fileisodate(dir_name) > start:
|
||||||
|
stop = fileisodate(dir_name)
|
||||||
|
else:
|
||||||
|
stop = start
|
||||||
|
self.logger.info("Registering %s started on %s", dir_name, start)
|
||||||
|
self.logger.debug(" Disk usage %s", 'du -sb "%s"' % dir_name)
|
||||||
|
if not self.dry_run:
|
||||||
|
size_bytes = int(os.popen('du -sb "%s"' % dir_name).read().split("\t")[0])
|
||||||
|
else:
|
||||||
|
size_bytes = 0
|
||||||
|
self.logger.debug(" Size in bytes : %i", size_bytes)
|
||||||
|
if not self.dry_run:
|
||||||
|
self.dbstat.add(
|
||||||
|
self.backup_name,
|
||||||
|
self.server_name,
|
||||||
|
"",
|
||||||
|
backup_start=start,
|
||||||
|
backup_end=stop,
|
||||||
|
status="OK",
|
||||||
|
total_bytes=size_bytes,
|
||||||
|
backup_location=dir_name,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.logger.info("Skipping %s, already registered", dir_name)
|
||||||
|
|
||||||
|
def clean_dumpfiles(self, dumpfile, dumplog):
|
||||||
|
cmd = 'rm -f "%s.gz" "%s"' % (dumpfile, dumplog)
|
||||||
|
self.logger.debug("[%s] Remove temp gzip : %s", self.backup_name, cmd)
|
||||||
|
if not self.dry_run:
|
||||||
|
(error_code, output) = ssh_exec(cmd, ssh=self.ssh)
|
||||||
|
self.logger.debug("[%s] Output of %s :\n%s", self.backup_name, cmd, output)
|
||||||
|
if error_code:
|
||||||
|
raise Exception('Aborting, Not null exit code (%i) for "%s"' % (error_code, cmd))
|
||||||
|
cmd = "rm -f " + self.remote_backup_dir + "/" + self.db_name + "_" + self.backup_start_date + ".dmp"
|
||||||
|
self.logger.debug("[%s] Remove temp dump : %s", self.backup_name, cmd)
|
||||||
|
if not self.dry_run:
|
||||||
|
(error_code, output) = ssh_exec(cmd, ssh=self.ssh)
|
||||||
|
self.logger.debug("[%s] Output of %s :\n%s", self.backup_name, cmd, output)
|
||||||
|
if error_code:
|
||||||
|
raise Exception('Aborting, Not null exit code (%i) for "%s"' % (error_code, cmd))
|
||||||
|
|
||||||
|
|
||||||
|
register_driver(backup_oracle)
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# This file is part of TISBackup
|
||||||
|
#
|
||||||
|
# TISBackup is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# TISBackup is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with TISBackup. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
sys.stderr = open("/dev/null") # Silence silly warnings from paramiko
|
||||||
|
import paramiko
|
||||||
|
except ImportError as e:
|
||||||
|
print(("Error : can not load paramiko library %s" % e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
sys.stderr = sys.__stderr__
|
||||||
|
|
||||||
|
from libtisbackup import *
|
||||||
|
|
||||||
|
|
||||||
|
class backup_pgsql(backup_generic):
|
||||||
|
"""Backup a postgresql database as gzipped sql file through ssh"""
|
||||||
|
|
||||||
|
type = "pgsql+ssh"
|
||||||
|
required_params = backup_generic.required_params + ["private_key"]
|
||||||
|
optional_params = backup_generic.optional_params + ["db_name", "tmp_dir", "encoding"]
|
||||||
|
|
||||||
|
db_name = ""
|
||||||
|
tmp_dir = "/tmp"
|
||||||
|
encoding = "UTF8"
|
||||||
|
|
||||||
|
def do_backup(self, stats):
|
||||||
|
self.dest_dir = os.path.join(self.backup_dir, self.backup_start_date)
|
||||||
|
|
||||||
|
if not os.path.isdir(self.dest_dir):
|
||||||
|
if not self.dry_run:
|
||||||
|
os.makedirs(self.dest_dir)
|
||||||
|
else:
|
||||||
|
print(('mkdir "%s"' % self.dest_dir))
|
||||||
|
else:
|
||||||
|
raise Exception("backup destination directory already exists : %s" % self.dest_dir)
|
||||||
|
|
||||||
|
mykey = load_ssh_private_key(self.private_key)
|
||||||
|
|
||||||
|
self.logger.debug(
|
||||||
|
'[%s] Trying to connect to "%s" with username root and key "%s"', self.backup_name, self.server_name, self.private_key
|
||||||
|
)
|
||||||
|
self.ssh = paramiko.SSHClient()
|
||||||
|
self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
self.ssh.connect(self.server_name, username="root", pkey=mykey, port=self.ssh_port)
|
||||||
|
|
||||||
|
if self.db_name:
|
||||||
|
stats["log"] = "Successfully backup processed to the following database :"
|
||||||
|
self.do_pgsqldump(stats)
|
||||||
|
else:
|
||||||
|
stats["log"] = "Successfully backuping processed to the following databases :"
|
||||||
|
stats["status"] = "List"
|
||||||
|
cmd = """su - postgres -c 'psql -A -t -c "SELECT datname FROM pg_database WHERE datistemplate = false;"' 2> /dev/null"""
|
||||||
|
self.logger.debug("[%s] List databases: %s", self.backup_name, cmd)
|
||||||
|
(error_code, output) = ssh_exec(cmd, ssh=self.ssh)
|
||||||
|
self.logger.debug("[%s] Output of %s :\n%s", self.backup_name, cmd, output)
|
||||||
|
if error_code:
|
||||||
|
raise Exception('Aborting, Not null exit code (%i) for "%s"' % (error_code, cmd))
|
||||||
|
databases = output.split("\n")
|
||||||
|
for database in databases:
|
||||||
|
if database.strip() not in ("", "template0", "template1"):
|
||||||
|
self.db_name = database.strip()
|
||||||
|
self.do_pgsqldump(stats)
|
||||||
|
|
||||||
|
stats["status"] = "OK"
|
||||||
|
|
||||||
|
def do_pgsqldump(self, stats):
|
||||||
|
t = datetime.datetime.now()
|
||||||
|
backup_start_date = t.strftime("%Y%m%d-%Hh%Mm%S")
|
||||||
|
params = {
|
||||||
|
"encoding": self.encoding,
|
||||||
|
"db_name": self.db_name,
|
||||||
|
"tmp_dir": self.tmp_dir,
|
||||||
|
"dest_dir": self.dest_dir,
|
||||||
|
"backup_start_date": backup_start_date,
|
||||||
|
}
|
||||||
|
# dump db
|
||||||
|
filepath = "%(tmp_dir)s/%(db_name)s-%(backup_start_date)s.sql.gz" % params
|
||||||
|
cmd = "su - postgres -c 'pg_dump -E %(encoding)s -Z9 %(db_name)s'" % params
|
||||||
|
cmd += " > " + filepath
|
||||||
|
self.logger.debug("[%s] %s ", self.backup_name, cmd)
|
||||||
|
if not self.dry_run:
|
||||||
|
(error_code, output) = ssh_exec(cmd, ssh=self.ssh)
|
||||||
|
self.logger.debug("[%s] Output of %s :\n%s", self.backup_name, cmd, output)
|
||||||
|
if error_code:
|
||||||
|
raise Exception('Aborting, Not null exit code (%i) for "%s"' % (error_code, cmd))
|
||||||
|
|
||||||
|
# get the file
|
||||||
|
localpath = "%(dest_dir)s/%(db_name)s-%(backup_start_date)s.sql.gz" % params
|
||||||
|
self.logger.debug('[%s] get the file using sftp from "%s" to "%s" ', self.backup_name, filepath, localpath)
|
||||||
|
if not self.dry_run:
|
||||||
|
transport = self.ssh.get_transport()
|
||||||
|
sftp = paramiko.SFTPClient.from_transport(transport)
|
||||||
|
sftp.get(filepath, localpath)
|
||||||
|
sftp.close()
|
||||||
|
|
||||||
|
if not self.dry_run:
|
||||||
|
stats["total_files_count"] = 1 + stats.get("total_files_count", 0)
|
||||||
|
stats["written_files_count"] = 1 + stats.get("written_files_count", 0)
|
||||||
|
stats["total_bytes"] = os.stat(localpath).st_size + stats.get("total_bytes", 0)
|
||||||
|
stats["written_bytes"] = os.stat(localpath).st_size + stats.get("written_bytes", 0)
|
||||||
|
stats["log"] = '%s "%s"' % (stats["log"], self.db_name)
|
||||||
|
stats["backup_location"] = self.dest_dir
|
||||||
|
|
||||||
|
cmd = "rm -f %(tmp_dir)s/%(db_name)s-%(backup_start_date)s.sql.gz" % params
|
||||||
|
self.logger.debug("[%s] %s ", self.backup_name, cmd)
|
||||||
|
if not self.dry_run:
|
||||||
|
(error_code, output) = ssh_exec(cmd, ssh=self.ssh)
|
||||||
|
self.logger.debug("[%s] Output of %s :\n%s", self.backup_name, cmd, output)
|
||||||
|
if error_code:
|
||||||
|
raise Exception('Aborting, Not null exit code (%i) for "%s"' % (error_code, cmd))
|
||||||
|
|
||||||
|
def register_existingbackups(self):
|
||||||
|
"""scan backup dir and insert stats in database"""
|
||||||
|
|
||||||
|
registered = [
|
||||||
|
b["backup_location"]
|
||||||
|
for b in self.dbstat.query("select distinct backup_location from stats where backup_name=?", (self.backup_name,))
|
||||||
|
]
|
||||||
|
|
||||||
|
filelist = os.listdir(self.backup_dir)
|
||||||
|
filelist.sort()
|
||||||
|
p = re.compile(r"^\d{8,8}-\d{2,2}h\d{2,2}m\d{2,2}$")
|
||||||
|
for item in filelist:
|
||||||
|
if p.match(item):
|
||||||
|
dir_name = os.path.join(self.backup_dir, item)
|
||||||
|
if dir_name not in registered:
|
||||||
|
start = datetime.datetime.strptime(item, "%Y%m%d-%Hh%Mm%S").isoformat()
|
||||||
|
if fileisodate(dir_name) > start:
|
||||||
|
stop = fileisodate(dir_name)
|
||||||
|
else:
|
||||||
|
stop = start
|
||||||
|
self.logger.info("Registering %s started on %s", dir_name, start)
|
||||||
|
self.logger.debug(" Disk usage %s", 'du -sb "%s"' % dir_name)
|
||||||
|
if not self.dry_run:
|
||||||
|
size_bytes = int(os.popen('du -sb "%s"' % dir_name).read().split("\t")[0])
|
||||||
|
else:
|
||||||
|
size_bytes = 0
|
||||||
|
self.logger.debug(" Size in bytes : %i", size_bytes)
|
||||||
|
if not self.dry_run:
|
||||||
|
self.dbstat.add(
|
||||||
|
self.backup_name,
|
||||||
|
self.server_name,
|
||||||
|
"",
|
||||||
|
backup_start=start,
|
||||||
|
backup_end=stop,
|
||||||
|
status="OK",
|
||||||
|
total_bytes=size_bytes,
|
||||||
|
backup_location=dir_name,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.logger.info("Skipping %s, already registered", dir_name)
|
||||||
|
|
||||||
|
|
||||||
|
register_driver(backup_pgsql)
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# This file is part of TISBackup
|
||||||
|
#
|
||||||
|
# TISBackup is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# TISBackup is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with TISBackup. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import os.path
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
|
||||||
|
from libtisbackup import *
|
||||||
|
|
||||||
|
|
||||||
|
class backup_rsync(backup_generic):
|
||||||
|
"""Backup a directory on remote server with rsync and rsync protocol (requires running remote rsync daemon)"""
|
||||||
|
|
||||||
|
type = "rsync"
|
||||||
|
required_params = backup_generic.required_params + ["remote_user", "remote_dir", "rsync_module", "password_file"]
|
||||||
|
optional_params = backup_generic.optional_params + [
|
||||||
|
"compressionlevel",
|
||||||
|
"compression",
|
||||||
|
"bwlimit",
|
||||||
|
"exclude_list",
|
||||||
|
"protect_args",
|
||||||
|
"overload_args",
|
||||||
|
]
|
||||||
|
|
||||||
|
remote_user = "root"
|
||||||
|
remote_dir = ""
|
||||||
|
|
||||||
|
exclude_list = ""
|
||||||
|
rsync_module = ""
|
||||||
|
password_file = ""
|
||||||
|
compression = ""
|
||||||
|
bwlimit = 0
|
||||||
|
protect_args = "1"
|
||||||
|
overload_args = None
|
||||||
|
compressionlevel = 0
|
||||||
|
|
||||||
|
def read_config(self, iniconf):
|
||||||
|
assert isinstance(iniconf, ConfigParser)
|
||||||
|
backup_generic.read_config(self, iniconf)
|
||||||
|
if not self.bwlimit and iniconf.has_option("global", "bw_limit"):
|
||||||
|
self.bwlimit = iniconf.getint("global", "bw_limit")
|
||||||
|
if not self.compressionlevel and iniconf.has_option("global", "compression_level"):
|
||||||
|
self.compressionlevel = iniconf.getint("global", "compression_level")
|
||||||
|
|
||||||
|
def do_backup(self, stats):
|
||||||
|
if not self.set_lock():
|
||||||
|
self.logger.error("[%s] a lock file is set, a backup maybe already running!!", self.backup_name)
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
backup_source = "undefined"
|
||||||
|
dest_dir = os.path.join(self.backup_dir, self.backup_start_date + ".rsync/")
|
||||||
|
if not os.path.isdir(dest_dir):
|
||||||
|
if not self.dry_run:
|
||||||
|
os.makedirs(dest_dir)
|
||||||
|
else:
|
||||||
|
print(('mkdir "%s"' % dest_dir))
|
||||||
|
else:
|
||||||
|
raise Exception("backup destination directory already exists : %s" % dest_dir)
|
||||||
|
|
||||||
|
options = ["-rt", "--stats", "--delete-excluded", "--numeric-ids", "--delete-after"]
|
||||||
|
if self.logger.level:
|
||||||
|
options.append("-P")
|
||||||
|
|
||||||
|
if self.dry_run:
|
||||||
|
options.append("-d")
|
||||||
|
|
||||||
|
if self.overload_args is not None:
|
||||||
|
options.append(self.overload_args)
|
||||||
|
elif "cygdrive" not in self.remote_dir:
|
||||||
|
# we don't preserve owner, group, links, hardlinks, perms for windows/cygwin as it is not reliable nor useful
|
||||||
|
options.append("-lpgoD")
|
||||||
|
|
||||||
|
# the protect-args option is not available in all rsync version
|
||||||
|
if self.protect_args.lower() not in ("false", "no", "0"):
|
||||||
|
options.append("--protect-args")
|
||||||
|
|
||||||
|
if self.compression.lower() in ("true", "yes", "1"):
|
||||||
|
options.append("-z")
|
||||||
|
|
||||||
|
if self.compressionlevel:
|
||||||
|
options.append("--compress-level=%s" % self.compressionlevel)
|
||||||
|
|
||||||
|
if self.bwlimit:
|
||||||
|
options.append("--bwlimit %s" % self.bwlimit)
|
||||||
|
|
||||||
|
latest = self.get_latest_backup(self.backup_start_date)
|
||||||
|
if latest:
|
||||||
|
options.extend(['--link-dest="%s"' % os.path.join("..", b, "") for b in latest])
|
||||||
|
|
||||||
|
def strip_quotes(s):
|
||||||
|
if s[0] == '"':
|
||||||
|
s = s[1:]
|
||||||
|
if s[-1] == '"':
|
||||||
|
s = s[:-1]
|
||||||
|
return s
|
||||||
|
|
||||||
|
# Add excludes
|
||||||
|
if "--exclude" in self.exclude_list:
|
||||||
|
# old settings with exclude_list=--exclude toto --exclude=titi
|
||||||
|
excludes = [
|
||||||
|
strip_quotes(s).strip() for s in self.exclude_list.replace("--exclude=", "").replace("--exclude ", "").split()
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
# newsettings with exclude_list='too','titi', parsed as a str python list content
|
||||||
|
excludes = eval("[%s]" % self.exclude_list)
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(
|
||||||
|
"Error reading exclude list : value %s, eval error %s (don't forget quotes and comma...)"
|
||||||
|
% (self.exclude_list, e)
|
||||||
|
)
|
||||||
|
options.extend(['--exclude="%s"' % x for x in excludes])
|
||||||
|
|
||||||
|
if self.rsync_module and not self.password_file:
|
||||||
|
raise Exception("You must specify a password file if you specify a rsync module")
|
||||||
|
|
||||||
|
if not self.rsync_module and not self.private_key:
|
||||||
|
raise Exception("If you don" "t use SSH, you must specify a rsync module")
|
||||||
|
|
||||||
|
# rsync_re = re.compile(r'(?P<server>[^:]*)::(?P<export>[^/]*)/(?P<path>.*)')
|
||||||
|
# ssh_re = re.compile(r'((?P<user>.*)@)?(?P<server>[^:]*):(?P<path>/.*)')
|
||||||
|
|
||||||
|
# Add ssh connection params
|
||||||
|
if self.rsync_module:
|
||||||
|
# Case of rsync exports
|
||||||
|
if self.password_file:
|
||||||
|
options.append('--password-file="%s"' % self.password_file)
|
||||||
|
backup_source = "%s@%s::%s%s" % (self.remote_user, self.server_name, self.rsync_module, self.remote_dir)
|
||||||
|
else:
|
||||||
|
# case of rsync + ssh
|
||||||
|
ssh_params = ["-o StrictHostKeyChecking=no"]
|
||||||
|
ssh_params.append("-o BatchMode=yes")
|
||||||
|
|
||||||
|
if self.private_key:
|
||||||
|
ssh_params.append("-i %s" % self.private_key)
|
||||||
|
if self.cipher_spec:
|
||||||
|
ssh_params.append("-c %s" % self.cipher_spec)
|
||||||
|
if self.ssh_port != 22:
|
||||||
|
ssh_params.append("-p %i" % self.ssh_port)
|
||||||
|
options.append('-e "/usr/bin/ssh %s"' % (" ".join(ssh_params)))
|
||||||
|
backup_source = "%s@%s:%s" % (self.remote_user, self.server_name, self.remote_dir)
|
||||||
|
|
||||||
|
# ensure there is a slash at end
|
||||||
|
if backup_source[-1] != "/":
|
||||||
|
backup_source += "/"
|
||||||
|
|
||||||
|
options_params = " ".join(options)
|
||||||
|
|
||||||
|
cmd = "/usr/bin/rsync %s %s %s 2>&1" % (options_params, backup_source, dest_dir)
|
||||||
|
self.logger.debug("[%s] rsync : %s", self.backup_name, cmd)
|
||||||
|
|
||||||
|
if not self.dry_run:
|
||||||
|
self.line = ""
|
||||||
|
process = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, close_fds=True)
|
||||||
|
|
||||||
|
def ondata(data, context):
|
||||||
|
if context.verbose:
|
||||||
|
print(data)
|
||||||
|
context.logger.debug(data)
|
||||||
|
|
||||||
|
log = monitor_stdout(process, ondata, self)
|
||||||
|
|
||||||
|
reg_total_files = re.compile(r"Number of files: (?P<file>\d+)")
|
||||||
|
reg_transferred_files = re.compile(r"Number of .*files transferred: (?P<file>\d+)")
|
||||||
|
for l in log.splitlines():
|
||||||
|
line = l.replace(",", "")
|
||||||
|
m = reg_total_files.match(line)
|
||||||
|
if m:
|
||||||
|
stats["total_files_count"] += int(m.groupdict()["file"])
|
||||||
|
m = reg_transferred_files.match(line)
|
||||||
|
if m:
|
||||||
|
stats["written_files_count"] += int(m.groupdict()["file"])
|
||||||
|
if line.startswith("Total file size:"):
|
||||||
|
stats["total_bytes"] += int(line.split(":")[1].split()[0])
|
||||||
|
if line.startswith("Total transferred file size:"):
|
||||||
|
stats["written_bytes"] += int(line.split(":")[1].split()[0])
|
||||||
|
|
||||||
|
returncode = process.returncode
|
||||||
|
## deal with exit code 24 (file vanished)
|
||||||
|
if returncode == 24:
|
||||||
|
self.logger.warning("[" + self.backup_name + "] Note: some files vanished before transfer")
|
||||||
|
elif returncode == 23:
|
||||||
|
self.logger.warning("[" + self.backup_name + "] unable so set uid on some files")
|
||||||
|
elif returncode != 0:
|
||||||
|
self.logger.error("[" + self.backup_name + "] shell program exited with error code " + str(returncode))
|
||||||
|
raise Exception(
|
||||||
|
"[" + self.backup_name + "] shell program exited with error code " + str(returncode), cmd, log[-512:]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(cmd)
|
||||||
|
|
||||||
|
# we suppress the .rsync suffix if everything went well
|
||||||
|
finaldest = os.path.join(self.backup_dir, self.backup_start_date)
|
||||||
|
self.logger.debug("[%s] renaming target directory from %s to %s", self.backup_name, dest_dir, finaldest)
|
||||||
|
if not self.dry_run:
|
||||||
|
os.rename(dest_dir, finaldest)
|
||||||
|
self.logger.debug("[%s] touching datetime of target directory %s", self.backup_name, finaldest)
|
||||||
|
print((os.popen('touch "%s"' % finaldest).read()))
|
||||||
|
else:
|
||||||
|
print(("mv", dest_dir, finaldest))
|
||||||
|
stats["backup_location"] = finaldest
|
||||||
|
stats["status"] = "OK"
|
||||||
|
stats["log"] = "ssh+rsync backup from %s OK, %d bytes written for %d changed files" % (
|
||||||
|
backup_source,
|
||||||
|
stats["written_bytes"],
|
||||||
|
stats["written_files_count"],
|
||||||
|
)
|
||||||
|
|
||||||
|
except BaseException as e:
|
||||||
|
stats["status"] = "ERROR"
|
||||||
|
stats["log"] = str(e)
|
||||||
|
raise
|
||||||
|
|
||||||
|
finally:
|
||||||
|
self.remove_lock()
|
||||||
|
|
||||||
|
def get_latest_backup(self, current):
|
||||||
|
result = []
|
||||||
|
filelist = os.listdir(self.backup_dir)
|
||||||
|
filelist.sort()
|
||||||
|
filelist.reverse()
|
||||||
|
# full = ''
|
||||||
|
r_full = re.compile(r"^\d{8,8}-\d{2,2}h\d{2,2}m\d{2,2}$")
|
||||||
|
r_partial = re.compile(r"^\d{8,8}-\d{2,2}h\d{2,2}m\d{2,2}.rsync$")
|
||||||
|
# we take all latest partials younger than the latest full and the latest full
|
||||||
|
for item in filelist:
|
||||||
|
if r_partial.match(item) and item < current:
|
||||||
|
result.append(item)
|
||||||
|
elif r_full.match(item) and item < current:
|
||||||
|
result.append(item)
|
||||||
|
break
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def register_existingbackups(self):
|
||||||
|
"""scan backup dir and insert stats in database"""
|
||||||
|
|
||||||
|
registered = [
|
||||||
|
b["backup_location"]
|
||||||
|
for b in self.dbstat.query("select distinct backup_location from stats where backup_name=?", (self.backup_name,))
|
||||||
|
]
|
||||||
|
|
||||||
|
filelist = os.listdir(self.backup_dir)
|
||||||
|
filelist.sort()
|
||||||
|
p = re.compile(r"^\d{8,8}-\d{2,2}h\d{2,2}m\d{2,2}$")
|
||||||
|
for item in filelist:
|
||||||
|
if p.match(item):
|
||||||
|
dir_name = os.path.join(self.backup_dir, item)
|
||||||
|
if dir_name not in registered:
|
||||||
|
start = datetime.datetime.strptime(item, "%Y%m%d-%Hh%Mm%S").isoformat()
|
||||||
|
if fileisodate(dir_name) > start:
|
||||||
|
stop = fileisodate(dir_name)
|
||||||
|
else:
|
||||||
|
stop = start
|
||||||
|
self.logger.info("Registering %s started on %s", dir_name, start)
|
||||||
|
self.logger.debug(" Disk usage %s", 'du -sb "%s"' % dir_name)
|
||||||
|
if not self.dry_run:
|
||||||
|
size_bytes = int(os.popen('du -sb "%s"' % dir_name).read().split("\t")[0])
|
||||||
|
else:
|
||||||
|
size_bytes = 0
|
||||||
|
self.logger.debug(" Size in bytes : %i", size_bytes)
|
||||||
|
if not self.dry_run:
|
||||||
|
self.dbstat.add(
|
||||||
|
self.backup_name,
|
||||||
|
self.server_name,
|
||||||
|
"",
|
||||||
|
backup_start=start,
|
||||||
|
backup_end=stop,
|
||||||
|
status="OK",
|
||||||
|
total_bytes=size_bytes,
|
||||||
|
backup_location=dir_name,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.logger.info("Skipping %s, already registered", dir_name)
|
||||||
|
|
||||||
|
def is_pid_still_running(self, lockfile):
|
||||||
|
f = open(lockfile)
|
||||||
|
lines = f.readlines()
|
||||||
|
f.close()
|
||||||
|
if len(lines) == 0:
|
||||||
|
self.logger.info("[" + self.backup_name + "] empty lock file, removing...")
|
||||||
|
return False
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
if line.startswith("pid="):
|
||||||
|
pid = line.split("=")[1].strip()
|
||||||
|
if os.path.exists("/proc/" + pid):
|
||||||
|
self.logger.info("[" + self.backup_name + "] process still there")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
self.logger.info("[" + self.backup_name + "] process not there anymore remove lock")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
self.logger.info("[" + self.backup_name + "] incorrrect lock file : no pid line")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def set_lock(self):
|
||||||
|
self.logger.debug("[" + self.backup_name + "] setting lock")
|
||||||
|
|
||||||
|
# TODO: improve for race condition
|
||||||
|
# TODO: also check if process is really there
|
||||||
|
if os.path.isfile(self.backup_dir + "/lock"):
|
||||||
|
self.logger.debug("[" + self.backup_name + "] File " + self.backup_dir + "/lock already exist")
|
||||||
|
if not self.is_pid_still_running(self.backup_dir + "/lock"):
|
||||||
|
self.logger.info("[" + self.backup_name + "] removing lock file " + self.backup_dir + "/lock")
|
||||||
|
os.unlink(self.backup_dir + "/lock")
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
lockfile = open(self.backup_dir + "/lock", "w")
|
||||||
|
# Write all the lines at once:
|
||||||
|
lockfile.write("pid=" + str(os.getpid()))
|
||||||
|
lockfile.write("\nbackup_time=" + self.backup_start_date)
|
||||||
|
lockfile.close()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def remove_lock(self):
|
||||||
|
self.logger.debug("[%s] removing lock", self.backup_name)
|
||||||
|
os.unlink(self.backup_dir + "/lock")
|
||||||
|
|
||||||
|
|
||||||
|
class backup_rsync_ssh(backup_rsync):
|
||||||
|
"""Backup a directory on remote server with rsync and ssh protocol (requires rsync software on remote host)"""
|
||||||
|
|
||||||
|
type = "rsync+ssh"
|
||||||
|
required_params = backup_generic.required_params + ["remote_user", "remote_dir", "private_key"]
|
||||||
|
optional_params = backup_generic.optional_params + [
|
||||||
|
"compression",
|
||||||
|
"bwlimit",
|
||||||
|
"ssh_port",
|
||||||
|
"exclude_list",
|
||||||
|
"protect_args",
|
||||||
|
"overload_args",
|
||||||
|
"cipher_spec",
|
||||||
|
]
|
||||||
|
cipher_spec = ""
|
||||||
|
|
||||||
|
|
||||||
|
register_driver(backup_rsync)
|
||||||
|
register_driver(backup_rsync_ssh)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
logger = logging.getLogger("tisbackup")
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
formatter = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
|
||||||
|
handler = logging.StreamHandler()
|
||||||
|
handler.setFormatter(formatter)
|
||||||
|
logger.addHandler(handler)
|
||||||
|
|
||||||
|
cp = ConfigParser()
|
||||||
|
cp.read("/opt/tisbackup/configtest.ini")
|
||||||
|
dbstat = BackupStat("/backup/data/log/tisbackup.sqlite")
|
||||||
|
b = backup_rsync("htouvet", "/backup/data/htouvet", dbstat)
|
||||||
|
b.read_config(cp)
|
||||||
|
b.process_backup()
|
||||||
|
print((b.checknagios()))
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# This file is part of TISBackup
|
||||||
|
#
|
||||||
|
# TISBackup is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# TISBackup is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with TISBackup. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import os.path
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
|
||||||
|
from libtisbackup import *
|
||||||
|
|
||||||
|
|
||||||
|
class backup_rsync_btrfs(backup_generic):
|
||||||
|
"""Backup a directory on remote server with rsync and btrfs protocol (requires running remote rsync daemon)"""
|
||||||
|
|
||||||
|
type = "rsync+btrfs"
|
||||||
|
required_params = backup_generic.required_params + ["remote_user", "remote_dir", "rsync_module", "password_file"]
|
||||||
|
optional_params = backup_generic.optional_params + [
|
||||||
|
"compressionlevel",
|
||||||
|
"compression",
|
||||||
|
"bwlimit",
|
||||||
|
"exclude_list",
|
||||||
|
"protect_args",
|
||||||
|
"overload_args",
|
||||||
|
]
|
||||||
|
|
||||||
|
remote_user = "root"
|
||||||
|
remote_dir = ""
|
||||||
|
|
||||||
|
exclude_list = ""
|
||||||
|
rsync_module = ""
|
||||||
|
password_file = ""
|
||||||
|
compression = ""
|
||||||
|
bwlimit = 0
|
||||||
|
protect_args = "1"
|
||||||
|
overload_args = None
|
||||||
|
compressionlevel = 0
|
||||||
|
|
||||||
|
def read_config(self, iniconf):
|
||||||
|
assert isinstance(iniconf, ConfigParser)
|
||||||
|
backup_generic.read_config(self, iniconf)
|
||||||
|
if not self.bwlimit and iniconf.has_option("global", "bw_limit"):
|
||||||
|
self.bwlimit = iniconf.getint("global", "bw_limit")
|
||||||
|
if not self.compressionlevel and iniconf.has_option("global", "compression_level"):
|
||||||
|
self.compressionlevel = iniconf.getint("global", "compression_level")
|
||||||
|
|
||||||
|
def do_backup(self, stats):
|
||||||
|
if not self.set_lock():
|
||||||
|
self.logger.error("[%s] a lock file is set, a backup maybe already running!!", self.backup_name)
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
backup_source = "undefined"
|
||||||
|
dest_dir = os.path.join(self.backup_dir, "last_backup")
|
||||||
|
if not os.path.isdir(dest_dir):
|
||||||
|
if not self.dry_run:
|
||||||
|
cmd = "/bin/btrfs subvolume create %s" % dest_dir
|
||||||
|
process = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, close_fds=True)
|
||||||
|
log = monitor_stdout(process, "", self)
|
||||||
|
returncode = process.returncode
|
||||||
|
if returncode != 0:
|
||||||
|
self.logger.error("[" + self.backup_name + "] shell program exited with error code: %s" % log)
|
||||||
|
raise Exception("[" + self.backup_name + "] shell program exited with error code " + str(returncode), cmd)
|
||||||
|
else:
|
||||||
|
self.logger.info("[" + self.backup_name + "] create btrs volume: %s" % dest_dir)
|
||||||
|
else:
|
||||||
|
print(('btrfs subvolume create "%s"' % dest_dir))
|
||||||
|
|
||||||
|
options = ["-rt", "--stats", "--delete-excluded", "--numeric-ids", "--delete-after"]
|
||||||
|
if self.logger.level:
|
||||||
|
options.append("-P")
|
||||||
|
|
||||||
|
if self.dry_run:
|
||||||
|
options.append("-d")
|
||||||
|
|
||||||
|
if self.overload_args is not None:
|
||||||
|
options.append(self.overload_args)
|
||||||
|
elif "cygdrive" not in self.remote_dir:
|
||||||
|
# we don't preserve owner, group, links, hardlinks, perms for windows/cygwin as it is not reliable nor useful
|
||||||
|
options.append("-lpgoD")
|
||||||
|
|
||||||
|
# the protect-args option is not available in all rsync version
|
||||||
|
if self.protect_args.lower() not in ("false", "no", "0"):
|
||||||
|
options.append("--protect-args")
|
||||||
|
|
||||||
|
if self.compression.lower() in ("true", "yes", "1"):
|
||||||
|
options.append("-z")
|
||||||
|
|
||||||
|
if self.compressionlevel:
|
||||||
|
options.append("--compress-level=%s" % self.compressionlevel)
|
||||||
|
|
||||||
|
if self.bwlimit:
|
||||||
|
options.append("--bwlimit %s" % self.bwlimit)
|
||||||
|
|
||||||
|
# latest = self.get_latest_backup(self.backup_start_date)
|
||||||
|
# remove link-dest replace by btrfs
|
||||||
|
# if latest:
|
||||||
|
# options.extend(['--link-dest="%s"' % os.path.join('..',b,'') for b in latest])
|
||||||
|
|
||||||
|
def strip_quotes(s):
|
||||||
|
if s[0] == '"':
|
||||||
|
s = s[1:]
|
||||||
|
if s[-1] == '"':
|
||||||
|
s = s[:-1]
|
||||||
|
return s
|
||||||
|
|
||||||
|
# Add excludes
|
||||||
|
if "--exclude" in self.exclude_list:
|
||||||
|
# old settings with exclude_list=--exclude toto --exclude=titi
|
||||||
|
excludes = [
|
||||||
|
strip_quotes(s).strip() for s in self.exclude_list.replace("--exclude=", "").replace("--exclude ", "").split()
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
# newsettings with exclude_list='too','titi', parsed as a str python list content
|
||||||
|
excludes = eval("[%s]" % self.exclude_list)
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(
|
||||||
|
"Error reading exclude list : value %s, eval error %s (don't forget quotes and comma...)"
|
||||||
|
% (self.exclude_list, e)
|
||||||
|
)
|
||||||
|
options.extend(['--exclude="%s"' % x for x in excludes])
|
||||||
|
|
||||||
|
if self.rsync_module and not self.password_file:
|
||||||
|
raise Exception("You must specify a password file if you specify a rsync module")
|
||||||
|
|
||||||
|
if not self.rsync_module and not self.private_key:
|
||||||
|
raise Exception("If you don" "t use SSH, you must specify a rsync module")
|
||||||
|
|
||||||
|
# rsync_re = re.compile(r'(?P<server>[^:]*)::(?P<export>[^/]*)/(?P<path>.*)')
|
||||||
|
# ssh_re = re.compile(r'((?P<user>.*)@)?(?P<server>[^:]*):(?P<path>/.*)')
|
||||||
|
|
||||||
|
# Add ssh connection params
|
||||||
|
if self.rsync_module:
|
||||||
|
# Case of rsync exports
|
||||||
|
if self.password_file:
|
||||||
|
options.append('--password-file="%s"' % self.password_file)
|
||||||
|
backup_source = "%s@%s::%s%s" % (self.remote_user, self.server_name, self.rsync_module, self.remote_dir)
|
||||||
|
else:
|
||||||
|
# case of rsync + ssh
|
||||||
|
ssh_params = ["-o StrictHostKeyChecking=no"]
|
||||||
|
if self.private_key:
|
||||||
|
ssh_params.append("-i %s" % self.private_key)
|
||||||
|
if self.cipher_spec:
|
||||||
|
ssh_params.append("-c %s" % self.cipher_spec)
|
||||||
|
if self.ssh_port != 22:
|
||||||
|
ssh_params.append("-p %i" % self.ssh_port)
|
||||||
|
options.append('-e "/usr/bin/ssh %s"' % (" ".join(ssh_params)))
|
||||||
|
backup_source = "%s@%s:%s" % (self.remote_user, self.server_name, self.remote_dir)
|
||||||
|
|
||||||
|
# ensure there is a slash at end
|
||||||
|
if backup_source[-1] != "/":
|
||||||
|
backup_source += "/"
|
||||||
|
|
||||||
|
options_params = " ".join(options)
|
||||||
|
|
||||||
|
cmd = "/usr/bin/rsync %s %s %s 2>&1" % (options_params, backup_source, dest_dir)
|
||||||
|
self.logger.debug("[%s] rsync : %s", self.backup_name, cmd)
|
||||||
|
|
||||||
|
if not self.dry_run:
|
||||||
|
self.line = ""
|
||||||
|
process = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, close_fds=True)
|
||||||
|
|
||||||
|
def ondata(data, context):
|
||||||
|
if context.verbose:
|
||||||
|
print(data)
|
||||||
|
context.logger.debug(data)
|
||||||
|
|
||||||
|
log = monitor_stdout(process, ondata, self)
|
||||||
|
|
||||||
|
reg_total_files = re.compile(r"Number of files: (?P<file>\d+)")
|
||||||
|
reg_transferred_files = re.compile(r"Number of .*files transferred: (?P<file>\d+)")
|
||||||
|
for l in log.splitlines():
|
||||||
|
line = l.replace(",", "")
|
||||||
|
m = reg_total_files.match(line)
|
||||||
|
if m:
|
||||||
|
stats["total_files_count"] += int(m.groupdict()["file"])
|
||||||
|
m = reg_transferred_files.match(line)
|
||||||
|
if m:
|
||||||
|
stats["written_files_count"] += int(m.groupdict()["file"])
|
||||||
|
if line.startswith("Total file size:"):
|
||||||
|
stats["total_bytes"] += int(line.split(":")[1].split()[0])
|
||||||
|
if line.startswith("Total transferred file size:"):
|
||||||
|
stats["written_bytes"] += int(line.split(":")[1].split()[0])
|
||||||
|
|
||||||
|
returncode = process.returncode
|
||||||
|
## deal with exit code 24 (file vanished)
|
||||||
|
if returncode == 24:
|
||||||
|
self.logger.warning("[" + self.backup_name + "] Note: some files vanished before transfer")
|
||||||
|
elif returncode == 23:
|
||||||
|
self.logger.warning("[" + self.backup_name + "] unable so set uid on some files")
|
||||||
|
elif returncode != 0:
|
||||||
|
self.logger.error("[" + self.backup_name + "] shell program exited with error code ", str(returncode))
|
||||||
|
raise Exception(
|
||||||
|
"[" + self.backup_name + "] shell program exited with error code " + str(returncode), cmd, log[-512:]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(cmd)
|
||||||
|
|
||||||
|
# we take a snapshot of last_backup if everything went well
|
||||||
|
finaldest = os.path.join(self.backup_dir, self.backup_start_date)
|
||||||
|
self.logger.debug("[%s] snapshoting last_backup directory from %s to %s", self.backup_name, dest_dir, finaldest)
|
||||||
|
if not os.path.isdir(finaldest):
|
||||||
|
if not self.dry_run:
|
||||||
|
cmd = "/bin/btrfs subvolume snapshot %s %s" % (dest_dir, finaldest)
|
||||||
|
process = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, close_fds=True)
|
||||||
|
log = monitor_stdout(process, "", self)
|
||||||
|
returncode = process.returncode
|
||||||
|
if returncode != 0:
|
||||||
|
self.logger.error("[" + self.backup_name + "] shell program exited with error code " + str(returncode))
|
||||||
|
raise Exception(
|
||||||
|
"[" + self.backup_name + "] shell program exited with error code " + str(returncode), cmd, log[-512:]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.logger.info("[" + self.backup_name + "] snapshot directory created %s" % finaldest)
|
||||||
|
else:
|
||||||
|
print(("btrfs snapshot of %s to %s" % (dest_dir, finaldest)))
|
||||||
|
else:
|
||||||
|
raise Exception("snapshot directory already exists : %s" % finaldest)
|
||||||
|
self.logger.debug("[%s] touching datetime of target directory %s", self.backup_name, finaldest)
|
||||||
|
print((os.popen('touch "%s"' % finaldest).read()))
|
||||||
|
stats["backup_location"] = finaldest
|
||||||
|
stats["status"] = "OK"
|
||||||
|
stats["log"] = "ssh+rsync+btrfs backup from %s OK, %d bytes written for %d changed files" % (
|
||||||
|
backup_source,
|
||||||
|
stats["written_bytes"],
|
||||||
|
stats["written_files_count"],
|
||||||
|
)
|
||||||
|
|
||||||
|
except BaseException as e:
|
||||||
|
stats["status"] = "ERROR"
|
||||||
|
stats["log"] = str(e)
|
||||||
|
raise
|
||||||
|
|
||||||
|
finally:
|
||||||
|
self.remove_lock()
|
||||||
|
|
||||||
|
def get_latest_backup(self, current):
|
||||||
|
result = []
|
||||||
|
filelist = os.listdir(self.backup_dir)
|
||||||
|
filelist.sort()
|
||||||
|
filelist.reverse()
|
||||||
|
# full = ''
|
||||||
|
r_full = re.compile(r"^\d{8,8}-\d{2,2}h\d{2,2}m\d{2,2}$")
|
||||||
|
r_partial = re.compile(r"^\d{8,8}-\d{2,2}h\d{2,2}m\d{2,2}.rsync$")
|
||||||
|
# we take all latest partials younger than the latest full and the latest full
|
||||||
|
for item in filelist:
|
||||||
|
if r_partial.match(item) and item < current:
|
||||||
|
result.append(item)
|
||||||
|
elif r_full.match(item) and item < current:
|
||||||
|
result.append(item)
|
||||||
|
break
|
||||||
|
return result
|
||||||
|
|
||||||
|
def register_existingbackups(self):
|
||||||
|
"""scan backup dir and insert stats in database"""
|
||||||
|
|
||||||
|
registered = [
|
||||||
|
b["backup_location"]
|
||||||
|
for b in self.dbstat.query("select distinct backup_location from stats where backup_name=?", (self.backup_name,))
|
||||||
|
]
|
||||||
|
|
||||||
|
filelist = os.listdir(self.backup_dir)
|
||||||
|
filelist.sort()
|
||||||
|
p = re.compile(r"^\d{8,8}-\d{2,2}h\d{2,2}m\d{2,2}$")
|
||||||
|
for item in filelist:
|
||||||
|
if p.match(item):
|
||||||
|
dir_name = os.path.join(self.backup_dir, item)
|
||||||
|
if dir_name not in registered:
|
||||||
|
start = datetime.datetime.strptime(item, "%Y%m%d-%Hh%Mm%S").isoformat()
|
||||||
|
if fileisodate(dir_name) > start:
|
||||||
|
stop = fileisodate(dir_name)
|
||||||
|
else:
|
||||||
|
stop = start
|
||||||
|
self.logger.info("Registering %s started on %s", dir_name, start)
|
||||||
|
self.logger.debug(" Disk usage %s", 'du -sb "%s"' % dir_name)
|
||||||
|
if not self.dry_run:
|
||||||
|
size_bytes = int(os.popen('du -sb "%s"' % dir_name).read().split("\t")[0])
|
||||||
|
else:
|
||||||
|
size_bytes = 0
|
||||||
|
self.logger.debug(" Size in bytes : %i", size_bytes)
|
||||||
|
if not self.dry_run:
|
||||||
|
self.dbstat.add(
|
||||||
|
self.backup_name,
|
||||||
|
self.server_name,
|
||||||
|
"",
|
||||||
|
backup_start=start,
|
||||||
|
backup_end=stop,
|
||||||
|
status="OK",
|
||||||
|
total_bytes=size_bytes,
|
||||||
|
backup_location=dir_name,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.logger.info("Skipping %s, already registered", dir_name)
|
||||||
|
|
||||||
|
def is_pid_still_running(self, lockfile):
|
||||||
|
f = open(lockfile)
|
||||||
|
lines = f.readlines()
|
||||||
|
f.close()
|
||||||
|
if len(lines) == 0:
|
||||||
|
self.logger.info("[" + self.backup_name + "] empty lock file, removing...")
|
||||||
|
return False
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
if line.startswith("pid="):
|
||||||
|
pid = line.split("=")[1].strip()
|
||||||
|
if os.path.exists("/proc/" + pid):
|
||||||
|
self.logger.info("[" + self.backup_name + "] process still there")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
self.logger.info("[" + self.backup_name + "] process not there anymore remove lock")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
self.logger.info("[" + self.backup_name + "] incorrrect lock file : no pid line")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def set_lock(self):
|
||||||
|
self.logger.debug("[" + self.backup_name + "] setting lock")
|
||||||
|
|
||||||
|
# TODO: improve for race condition
|
||||||
|
# TODO: also check if process is really there
|
||||||
|
if os.path.isfile(self.backup_dir + "/lock"):
|
||||||
|
self.logger.debug("[" + self.backup_name + "] File " + self.backup_dir + "/lock already exist")
|
||||||
|
if not self.is_pid_still_running(self.backup_dir + "/lock"):
|
||||||
|
self.logger.info("[" + self.backup_name + "] removing lock file " + self.backup_dir + "/lock")
|
||||||
|
os.unlink(self.backup_dir + "/lock")
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
lockfile = open(self.backup_dir + "/lock", "w")
|
||||||
|
# Write all the lines at once:
|
||||||
|
lockfile.write("pid=" + str(os.getpid()))
|
||||||
|
lockfile.write("\nbackup_time=" + self.backup_start_date)
|
||||||
|
lockfile.close()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def remove_lock(self):
|
||||||
|
self.logger.debug("[%s] removing lock", self.backup_name)
|
||||||
|
os.unlink(self.backup_dir + "/lock")
|
||||||
|
|
||||||
|
|
||||||
|
class backup_rsync__btrfs_ssh(backup_rsync_btrfs):
|
||||||
|
"""Backup a directory on remote server with rsync,ssh and btrfs protocol (requires rsync software on remote host)"""
|
||||||
|
|
||||||
|
type = "rsync+btrfs+ssh"
|
||||||
|
required_params = backup_generic.required_params + ["remote_user", "remote_dir", "private_key"]
|
||||||
|
optional_params = backup_generic.optional_params + [
|
||||||
|
"compression",
|
||||||
|
"bwlimit",
|
||||||
|
"ssh_port",
|
||||||
|
"exclude_list",
|
||||||
|
"protect_args",
|
||||||
|
"overload_args",
|
||||||
|
"cipher_spec",
|
||||||
|
]
|
||||||
|
cipher_spec = ""
|
||||||
|
|
||||||
|
|
||||||
|
register_driver(backup_rsync_btrfs)
|
||||||
|
register_driver(backup_rsync__btrfs_ssh)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
logger = logging.getLogger("tisbackup")
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
formatter = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
|
||||||
|
handler = logging.StreamHandler()
|
||||||
|
handler.setFormatter(formatter)
|
||||||
|
logger.addHandler(handler)
|
||||||
|
|
||||||
|
cp = ConfigParser()
|
||||||
|
cp.read("/opt/tisbackup/configtest.ini")
|
||||||
|
dbstat = BackupStat("/backup/data/log/tisbackup.sqlite")
|
||||||
|
b = backup_rsync("htouvet", "/backup/data/htouvet", dbstat)
|
||||||
|
b.read_config(cp)
|
||||||
|
b.process_backup()
|
||||||
|
print((b.checknagios()))
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# This file is part of TISBackup
|
||||||
|
#
|
||||||
|
# TISBackup is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# TISBackup is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with TISBackup. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
sys.stderr = open("/dev/null") # Silence silly warnings from paramiko
|
||||||
|
import paramiko
|
||||||
|
except ImportError as e:
|
||||||
|
print("Error : can not load paramiko library %s" % e)
|
||||||
|
raise
|
||||||
|
|
||||||
|
sys.stderr = sys.__stderr__
|
||||||
|
|
||||||
|
from libtisbackup import *
|
||||||
|
|
||||||
|
|
||||||
|
class backup_samba4(backup_generic):
|
||||||
|
"""Backup a samba4 databases as gzipped tdbs file through ssh"""
|
||||||
|
|
||||||
|
type = "samba4"
|
||||||
|
required_params = backup_generic.required_params + ["private_key"]
|
||||||
|
optional_params = backup_generic.optional_params + ["root_dir_samba"]
|
||||||
|
|
||||||
|
root_dir_samba = "/var/lib/samba/"
|
||||||
|
|
||||||
|
def do_backup(self, stats):
|
||||||
|
self.dest_dir = os.path.join(self.backup_dir, self.backup_start_date)
|
||||||
|
|
||||||
|
if not os.path.isdir(self.dest_dir):
|
||||||
|
if not self.dry_run:
|
||||||
|
os.makedirs(self.dest_dir)
|
||||||
|
else:
|
||||||
|
print('mkdir "%s"' % self.dest_dir)
|
||||||
|
else:
|
||||||
|
raise Exception("backup destination directory already exists : %s" % self.dest_dir)
|
||||||
|
|
||||||
|
self.logger.debug("[%s] Connecting to %s with user root and key %s", self.backup_name, self.server_name, self.private_key)
|
||||||
|
mykey = load_ssh_private_key(self.private_key)
|
||||||
|
|
||||||
|
self.ssh = paramiko.SSHClient()
|
||||||
|
self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
self.ssh.connect(self.server_name, username="root", pkey=mykey, port=self.ssh_port)
|
||||||
|
|
||||||
|
stats["log"] = "Successfully backuping processed to the following databases :"
|
||||||
|
stats["status"] = "List"
|
||||||
|
dir_ldbs = os.path.join(self.root_dir_samba + "/private/sam.ldb.d/")
|
||||||
|
cmd = "ls %s/*.ldb 2> /dev/null" % dir_ldbs
|
||||||
|
self.logger.debug("[%s] List databases: %s", self.backup_name, cmd)
|
||||||
|
(error_code, output) = ssh_exec(cmd, ssh=self.ssh)
|
||||||
|
self.logger.debug("[%s] Output of %s :\n%s", self.backup_name, cmd, output)
|
||||||
|
if error_code:
|
||||||
|
raise Exception('Aborting, Not null exit code (%i) for "%s"' % (error_code, cmd))
|
||||||
|
databases = output.split("\n")
|
||||||
|
for database in databases:
|
||||||
|
if database != "":
|
||||||
|
self.db_name = database.rstrip()
|
||||||
|
self.do_mysqldump(stats)
|
||||||
|
|
||||||
|
def do_mysqldump(self, stats):
|
||||||
|
# t = datetime.datetime.now()
|
||||||
|
# backup_start_date = t.strftime('%Y%m%d-%Hh%Mm%S')
|
||||||
|
|
||||||
|
# dump db
|
||||||
|
stats["status"] = "Dumping"
|
||||||
|
cmd = "tdbbackup -s .tisbackup " + self.db_name
|
||||||
|
self.logger.debug("[%s] Dump DB : %s", self.backup_name, cmd)
|
||||||
|
if not self.dry_run:
|
||||||
|
(error_code, output) = ssh_exec(cmd, ssh=self.ssh)
|
||||||
|
print(output)
|
||||||
|
self.logger.debug("[%s] Output of %s :\n%s", self.backup_name, cmd, output)
|
||||||
|
if error_code:
|
||||||
|
raise Exception('Aborting, Not null exit code (%i) for "%s"' % (error_code, cmd))
|
||||||
|
|
||||||
|
# zip the file
|
||||||
|
stats["status"] = "Zipping"
|
||||||
|
cmd = 'gzip -f "%s.tisbackup"' % self.db_name
|
||||||
|
self.logger.debug("[%s] Compress backup : %s", self.backup_name, cmd)
|
||||||
|
if not self.dry_run:
|
||||||
|
(error_code, output) = ssh_exec(cmd, ssh=self.ssh)
|
||||||
|
self.logger.debug("[%s] Output of %s :\n%s", self.backup_name, cmd, output)
|
||||||
|
if error_code:
|
||||||
|
raise Exception('Aborting, Not null exit code (%i) for "%s"' % (error_code, cmd))
|
||||||
|
|
||||||
|
# get the file
|
||||||
|
stats["status"] = "SFTP"
|
||||||
|
filepath = self.db_name + ".tisbackup.gz"
|
||||||
|
localpath = os.path.join(self.dest_dir, os.path.basename(self.db_name) + ".tisbackup.gz")
|
||||||
|
self.logger.debug("[%s] Get gz backup with sftp on %s from %s to %s", self.backup_name, self.server_name, filepath, localpath)
|
||||||
|
if not self.dry_run:
|
||||||
|
transport = self.ssh.get_transport()
|
||||||
|
sftp = paramiko.SFTPClient.from_transport(transport)
|
||||||
|
sftp.get(filepath, localpath)
|
||||||
|
sftp.close()
|
||||||
|
|
||||||
|
if not self.dry_run:
|
||||||
|
stats["total_files_count"] = 1 + stats.get("total_files_count", 0)
|
||||||
|
stats["written_files_count"] = 1 + stats.get("written_files_count", 0)
|
||||||
|
stats["total_bytes"] = os.stat(localpath).st_size + stats.get("total_bytes", 0)
|
||||||
|
stats["written_bytes"] = os.stat(localpath).st_size + stats.get("written_bytes", 0)
|
||||||
|
stats["log"] = '%s "%s"' % (stats["log"], self.db_name)
|
||||||
|
stats["backup_location"] = self.dest_dir
|
||||||
|
|
||||||
|
stats["status"] = "RMTemp"
|
||||||
|
cmd = 'rm -f "%s"' % filepath
|
||||||
|
self.logger.debug("[%s] Remove temp gzip : %s", self.backup_name, cmd)
|
||||||
|
if not self.dry_run:
|
||||||
|
(error_code, output) = ssh_exec(cmd, ssh=self.ssh)
|
||||||
|
self.logger.debug("[%s] Output of %s :\n%s", self.backup_name, cmd, output)
|
||||||
|
if error_code:
|
||||||
|
raise Exception('Aborting, Not null exit code (%i) for "%s"' % (error_code, cmd))
|
||||||
|
stats["status"] = "OK"
|
||||||
|
|
||||||
|
def register_existingbackups(self):
|
||||||
|
"""scan backup dir and insert stats in database"""
|
||||||
|
|
||||||
|
registered = [
|
||||||
|
b["backup_location"]
|
||||||
|
for b in self.dbstat.query("select distinct backup_location from stats where backup_name=?", (self.backup_name,))
|
||||||
|
]
|
||||||
|
|
||||||
|
filelist = os.listdir(self.backup_dir)
|
||||||
|
filelist.sort()
|
||||||
|
p = re.compile(r"^\d{8,8}-\d{2,2}h\d{2,2}m\d{2,2}$")
|
||||||
|
for item in filelist:
|
||||||
|
if p.match(item):
|
||||||
|
dir_name = os.path.join(self.backup_dir, item)
|
||||||
|
if dir_name not in registered:
|
||||||
|
start = datetime.datetime.strptime(item, "%Y%m%d-%Hh%Mm%S").isoformat()
|
||||||
|
if fileisodate(dir_name) > start:
|
||||||
|
stop = fileisodate(dir_name)
|
||||||
|
else:
|
||||||
|
stop = start
|
||||||
|
self.logger.info("Registering %s started on %s", dir_name, start)
|
||||||
|
self.logger.debug(" Disk usage %s", 'du -sb "%s"' % dir_name)
|
||||||
|
if not self.dry_run:
|
||||||
|
size_bytes = int(os.popen('du -sb "%s"' % dir_name).read().split("\t")[0])
|
||||||
|
else:
|
||||||
|
size_bytes = 0
|
||||||
|
self.logger.debug(" Size in bytes : %i", size_bytes)
|
||||||
|
if not self.dry_run:
|
||||||
|
self.dbstat.add(
|
||||||
|
self.backup_name,
|
||||||
|
self.server_name,
|
||||||
|
"",
|
||||||
|
backup_start=start,
|
||||||
|
backup_end=stop,
|
||||||
|
status="OK",
|
||||||
|
total_bytes=size_bytes,
|
||||||
|
backup_location=dir_name,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.logger.info("Skipping %s, already registered", dir_name)
|
||||||
|
|
||||||
|
|
||||||
|
register_driver(backup_samba4)
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
#!/usr/bin/python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# This file is part of TISBackup
|
||||||
|
#
|
||||||
|
# TISBackup is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# TISBackup is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with TISBackup. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
sys.stderr = open("/dev/null") # Silence silly warnings from paramiko
|
||||||
|
import paramiko
|
||||||
|
except ImportError as e:
|
||||||
|
print("Error : can not load paramiko library %s" % e)
|
||||||
|
raise
|
||||||
|
|
||||||
|
sys.stderr = sys.__stderr__
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
|
||||||
|
from libtisbackup import *
|
||||||
|
|
||||||
|
|
||||||
|
class backup_sqlserver(backup_generic):
|
||||||
|
"""Backup a SQLSERVER database as gzipped sql file through ssh"""
|
||||||
|
|
||||||
|
type = "sqlserver+ssh"
|
||||||
|
required_params = backup_generic.required_params + ["db_name", "private_key"]
|
||||||
|
optional_params = ["username", "remote_backup_dir", "sqlserver_before_2005", "db_server_name", "db_user", "db_password"]
|
||||||
|
db_name = ""
|
||||||
|
db_user = ""
|
||||||
|
db_password = ""
|
||||||
|
userdb = "-E"
|
||||||
|
username = "Administrateur"
|
||||||
|
remote_backup_dir = r"c:/WINDOWS/Temp/"
|
||||||
|
sqlserver_before_2005 = False
|
||||||
|
db_server_name = "localhost"
|
||||||
|
|
||||||
|
def do_backup(self, stats):
|
||||||
|
mykey = load_ssh_private_key(self.private_key)
|
||||||
|
|
||||||
|
self.logger.debug("[%s] Connecting to %s with user root and key %s", self.backup_name, self.server_name, self.private_key)
|
||||||
|
ssh = paramiko.SSHClient()
|
||||||
|
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
ssh.connect(self.server_name, username=self.username, pkey=mykey, port=self.ssh_port)
|
||||||
|
|
||||||
|
t = datetime.datetime.now()
|
||||||
|
backup_start_date = t.strftime("%Y%m%d-%Hh%Mm%S")
|
||||||
|
|
||||||
|
backup_file = self.remote_backup_dir + "/" + self.db_name + "-" + backup_start_date + ".bak"
|
||||||
|
if not self.db_user == "":
|
||||||
|
self.userdb = "-U %s -P %s" % (self.db_user, self.db_password)
|
||||||
|
|
||||||
|
# dump db
|
||||||
|
stats["status"] = "Dumping"
|
||||||
|
if self.sqlserver_before_2005:
|
||||||
|
cmd = """osql -E -Q "BACKUP DATABASE [%s]
|
||||||
|
TO DISK='%s'
|
||||||
|
WITH FORMAT" """ % (self.db_name, backup_file)
|
||||||
|
else:
|
||||||
|
cmd = """sqlcmd %s -S "%s" -d master -Q "BACKUP DATABASE [%s]
|
||||||
|
TO DISK = N'%s'
|
||||||
|
WITH INIT, NOUNLOAD ,
|
||||||
|
NAME = N'Backup %s', NOSKIP ,STATS = 10, NOFORMAT" """ % (
|
||||||
|
self.userdb,
|
||||||
|
self.db_server_name,
|
||||||
|
self.db_name,
|
||||||
|
backup_file,
|
||||||
|
self.db_name,
|
||||||
|
)
|
||||||
|
self.logger.debug("[%s] Dump DB : %s", self.backup_name, cmd)
|
||||||
|
try:
|
||||||
|
if not self.dry_run:
|
||||||
|
(error_code, output) = ssh_exec(cmd, ssh=ssh)
|
||||||
|
self.logger.debug("[%s] Output of %s :\n%s", self.backup_name, cmd, output)
|
||||||
|
if error_code:
|
||||||
|
raise Exception('Aborting, Not null exit code (%i) for "%s"' % (error_code, cmd))
|
||||||
|
|
||||||
|
# zip the file
|
||||||
|
stats["status"] = "Zipping"
|
||||||
|
cmd = 'gzip "%s"' % backup_file
|
||||||
|
self.logger.debug("[%s] Compress backup : %s", self.backup_name, cmd)
|
||||||
|
if not self.dry_run:
|
||||||
|
(error_code, output) = ssh_exec(cmd, ssh=ssh)
|
||||||
|
self.logger.debug("[%s] Output of %s :\n%s", self.backup_name, cmd, output)
|
||||||
|
if error_code:
|
||||||
|
raise Exception('Aborting, Not null exit code (%i) for "%s"' % (error_code, cmd))
|
||||||
|
|
||||||
|
# get the file
|
||||||
|
stats["status"] = "SFTP"
|
||||||
|
filepath = backup_file + ".gz"
|
||||||
|
localpath = os.path.join(self.backup_dir, self.db_name + "-" + backup_start_date + ".bak.gz")
|
||||||
|
self.logger.debug("[%s] Get gz backup with sftp on %s from %s to %s", self.backup_name, self.server_name, filepath, localpath)
|
||||||
|
if not self.dry_run:
|
||||||
|
transport = ssh.get_transport()
|
||||||
|
sftp = paramiko.SFTPClient.from_transport(transport)
|
||||||
|
sftp.get(filepath, localpath)
|
||||||
|
sftp.close()
|
||||||
|
|
||||||
|
if not self.dry_run:
|
||||||
|
stats["total_files_count"] = 1
|
||||||
|
stats["written_files_count"] = 1
|
||||||
|
stats["total_bytes"] = os.stat(localpath).st_size
|
||||||
|
stats["written_bytes"] = os.stat(localpath).st_size
|
||||||
|
stats["log"] = "gzip dump of DB %s:%s (%d bytes) to %s" % (self.server_name, self.db_name, stats["written_bytes"], localpath)
|
||||||
|
stats["backup_location"] = localpath
|
||||||
|
|
||||||
|
finally:
|
||||||
|
stats["status"] = "RMTemp"
|
||||||
|
cmd = 'rm -f "%s" "%s"' % (backup_file + ".gz", backup_file)
|
||||||
|
self.logger.debug("[%s] Remove temp gzip : %s", self.backup_name, cmd)
|
||||||
|
if not self.dry_run:
|
||||||
|
(error_code, output) = ssh_exec(cmd, ssh=ssh)
|
||||||
|
self.logger.debug("[%s] Output of %s :\n%s", self.backup_name, cmd, output)
|
||||||
|
if error_code:
|
||||||
|
raise Exception('Aborting, Not null exit code (%i) for "%s"' % (error_code, cmd))
|
||||||
|
|
||||||
|
stats["status"] = "OK"
|
||||||
|
|
||||||
|
def register_existingbackups(self):
|
||||||
|
"""scan backup dir and insert stats in database"""
|
||||||
|
|
||||||
|
registered = [
|
||||||
|
b["backup_location"]
|
||||||
|
for b in self.dbstat.query("select distinct backup_location from stats where backup_name=?", (self.backup_name,))
|
||||||
|
]
|
||||||
|
|
||||||
|
filelist = os.listdir(self.backup_dir)
|
||||||
|
filelist.sort()
|
||||||
|
p = re.compile(r"^%s-(?P<date>\d{8,8}-\d{2,2}h\d{2,2}m\d{2,2}).bak.gz$" % self.db_name)
|
||||||
|
for item in filelist:
|
||||||
|
sr = p.match(item)
|
||||||
|
if sr:
|
||||||
|
file_name = os.path.join(self.backup_dir, item)
|
||||||
|
start = datetime.datetime.strptime(sr.groups()[0], "%Y%m%d-%Hh%Mm%S").isoformat()
|
||||||
|
if file_name not in registered:
|
||||||
|
self.logger.info("Registering %s from %s", file_name, fileisodate(file_name))
|
||||||
|
size_bytes = int(os.popen('du -sb "%s"' % file_name).read().split("\t")[0])
|
||||||
|
self.logger.debug(" Size in bytes : %i", size_bytes)
|
||||||
|
if not self.dry_run:
|
||||||
|
self.dbstat.add(
|
||||||
|
self.backup_name,
|
||||||
|
self.server_name,
|
||||||
|
"",
|
||||||
|
backup_start=start,
|
||||||
|
backup_end=fileisodate(file_name),
|
||||||
|
status="OK",
|
||||||
|
total_bytes=size_bytes,
|
||||||
|
backup_location=file_name,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.logger.info("Skipping %s from %s, already registered", file_name, fileisodate(file_name))
|
||||||
|
|
||||||
|
|
||||||
|
register_driver(backup_sqlserver)
|
||||||
@@ -18,36 +18,39 @@
|
|||||||
#
|
#
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
import os
|
|
||||||
import datetime
|
|
||||||
from .common import *
|
|
||||||
from . import XenAPI
|
|
||||||
import time
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
import os.path
|
|
||||||
import datetime
|
|
||||||
import select
|
|
||||||
import urllib.request, urllib.error, urllib.parse, urllib.request, urllib.parse, urllib.error
|
|
||||||
import base64
|
import base64
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import os.path
|
||||||
|
import re
|
||||||
|
import select
|
||||||
import socket
|
import socket
|
||||||
import requests
|
import time
|
||||||
import pexpect
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
from stat import *
|
from stat import *
|
||||||
|
|
||||||
|
import pexpect
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from . import XenAPI
|
||||||
|
from libtisbackup import *
|
||||||
|
|
||||||
|
|
||||||
class backup_switch(backup_generic):
|
class backup_switch(backup_generic):
|
||||||
"""Backup a startup-config on a switch"""
|
"""Backup a startup-config on a switch"""
|
||||||
type = 'switch'
|
|
||||||
|
|
||||||
required_params = backup_generic.required_params + ['switch_ip','switch_type']
|
type = "switch"
|
||||||
optional_params = backup_generic.optional_params + [ 'switch_user', 'switch_password']
|
|
||||||
|
|
||||||
switch_user = ''
|
required_params = backup_generic.required_params + ["switch_ip", "switch_type"]
|
||||||
switch_password = ''
|
optional_params = backup_generic.optional_params + ["switch_user", "switch_password"]
|
||||||
|
|
||||||
|
switch_user = ""
|
||||||
|
switch_password = ""
|
||||||
|
|
||||||
def switch_hp(self, filename):
|
def switch_hp(self, filename):
|
||||||
|
|
||||||
s = socket.socket()
|
s = socket.socket()
|
||||||
try:
|
try:
|
||||||
s.connect((self.switch_ip, 23))
|
s.connect((self.switch_ip, 23))
|
||||||
@@ -55,31 +58,31 @@ class backup_switch(backup_generic):
|
|||||||
except:
|
except:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
child=pexpect.spawn('telnet '+self.switch_ip)
|
child = pexpect.spawn("telnet " + self.switch_ip)
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
if self.switch_user != "":
|
if self.switch_user != "":
|
||||||
child.sendline(self.switch_user)
|
child.sendline(self.switch_user)
|
||||||
child.sendline(self.switch_password+'\r')
|
child.sendline(self.switch_password + "\r")
|
||||||
else:
|
else:
|
||||||
child.sendline(self.switch_password+'\r')
|
child.sendline(self.switch_password + "\r")
|
||||||
try:
|
try:
|
||||||
child.expect("#")
|
child.expect("#")
|
||||||
except:
|
except:
|
||||||
raise Exception("Bad Credentials")
|
raise Exception("Bad Credentials")
|
||||||
child.sendline( "terminal length 1000\r")
|
child.sendline("terminal length 1000\r")
|
||||||
child.expect("#")
|
child.expect("#")
|
||||||
child.sendline( "show config\r")
|
child.sendline("show config\r")
|
||||||
child.maxread = 100000000
|
child.maxread = 100000000
|
||||||
child.expect("Startup.+$")
|
child.expect("Startup.+$")
|
||||||
lines = child.after
|
lines = child.after
|
||||||
if "-- MORE --" in lines:
|
if "-- MORE --" in lines:
|
||||||
raise Exception("Terminal lenght is not sufficient")
|
raise Exception("Terminal lenght is not sufficient")
|
||||||
child.expect("#")
|
child.expect("#")
|
||||||
lines += child.before
|
lines += child.before
|
||||||
child.sendline("logout\r")
|
child.sendline("logout\r")
|
||||||
child.send('y\r')
|
child.send("y\r")
|
||||||
for line in lines.split("\n")[1:-1]:
|
for line in lines.split("\n")[1:-1]:
|
||||||
open(filename,"a").write(line.strip()+"\n")
|
open(filename, "a").write(line.strip() + "\n")
|
||||||
|
|
||||||
def switch_cisco(self, filename):
|
def switch_cisco(self, filename):
|
||||||
s = socket.socket()
|
s = socket.socket()
|
||||||
@@ -89,38 +92,37 @@ class backup_switch(backup_generic):
|
|||||||
except:
|
except:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
child=pexpect.spawn('telnet '+self.switch_ip)
|
child = pexpect.spawn("telnet " + self.switch_ip)
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
if self.switch_user:
|
if self.switch_user:
|
||||||
child.sendline(self.switch_user)
|
child.sendline(self.switch_user)
|
||||||
child.expect('Password: ')
|
child.expect("Password: ")
|
||||||
child.sendline(self.switch_password+'\r')
|
child.sendline(self.switch_password + "\r")
|
||||||
try:
|
try:
|
||||||
child.expect(">")
|
child.expect(">")
|
||||||
except:
|
except:
|
||||||
raise Exception("Bad Credentials")
|
raise Exception("Bad Credentials")
|
||||||
child.sendline('enable\r')
|
child.sendline("enable\r")
|
||||||
child.expect('Password: ')
|
child.expect("Password: ")
|
||||||
child.sendline(self.switch_password+'\r')
|
child.sendline(self.switch_password + "\r")
|
||||||
try:
|
try:
|
||||||
child.expect("#")
|
child.expect("#")
|
||||||
except:
|
except:
|
||||||
raise Exception("Bad Credentials")
|
raise Exception("Bad Credentials")
|
||||||
child.sendline( "terminal length 0\r")
|
child.sendline("terminal length 0\r")
|
||||||
child.expect("#")
|
child.expect("#")
|
||||||
child.sendline("show run\r")
|
child.sendline("show run\r")
|
||||||
child.expect('Building configuration...')
|
child.expect("Building configuration...")
|
||||||
child.expect("#")
|
child.expect("#")
|
||||||
running_config = child.before
|
running_config = child.before
|
||||||
child.sendline("show vlan\r")
|
child.sendline("show vlan\r")
|
||||||
child.expect('VLAN')
|
child.expect("VLAN")
|
||||||
child.expect("#")
|
child.expect("#")
|
||||||
vlan = 'VLAN'+child.before
|
vlan = "VLAN" + child.before
|
||||||
open(filename,"a").write(running_config+'\n'+vlan)
|
open(filename, "a").write(running_config + "\n" + vlan)
|
||||||
child.send('exit\r')
|
child.send("exit\r")
|
||||||
child.close()
|
child.close()
|
||||||
|
|
||||||
|
|
||||||
def switch_linksys_SRW2024(self, filename):
|
def switch_linksys_SRW2024(self, filename):
|
||||||
s = socket.socket()
|
s = socket.socket()
|
||||||
try:
|
try:
|
||||||
@@ -129,48 +131,53 @@ class backup_switch(backup_generic):
|
|||||||
except:
|
except:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
child=pexpect.spawn('telnet '+self.switch_ip)
|
child = pexpect.spawn("telnet " + self.switch_ip)
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
if hasattr(self,'switch_password'):
|
if hasattr(self, "switch_password"):
|
||||||
child.sendline(self.switch_user+'\t')
|
child.sendline(self.switch_user + "\t")
|
||||||
child.sendline(self.switch_password+'\r')
|
child.sendline(self.switch_password + "\r")
|
||||||
else:
|
else:
|
||||||
child.sendline(self.switch_user+'\r')
|
child.sendline(self.switch_user + "\r")
|
||||||
try:
|
try:
|
||||||
child.expect('Menu')
|
child.expect("Menu")
|
||||||
except:
|
except:
|
||||||
raise Exception("Bad Credentials")
|
raise Exception("Bad Credentials")
|
||||||
child.sendline('\032')
|
child.sendline("\032")
|
||||||
child.expect('>')
|
child.expect(">")
|
||||||
child.sendline('lcli')
|
child.sendline("lcli")
|
||||||
child.expect("Name:")
|
child.expect("Name:")
|
||||||
if hasattr(self,'switch_password'):
|
if hasattr(self, "switch_password"):
|
||||||
child.send(self.switch_user+'\r'+self.switch_password+'\r')
|
child.send(self.switch_user + "\r" + self.switch_password + "\r")
|
||||||
else:
|
else:
|
||||||
child.sendline(self.switch_user)
|
child.sendline(self.switch_user)
|
||||||
child.expect(".*#")
|
child.expect(".*#")
|
||||||
|
|
||||||
child.sendline( "terminal datadump")
|
child.sendline("terminal datadump")
|
||||||
child.expect("#")
|
child.expect("#")
|
||||||
child.sendline( "show startup-config")
|
child.sendline("show startup-config")
|
||||||
child.expect("#")
|
child.expect("#")
|
||||||
lines = child.before
|
lines = child.before
|
||||||
if "Unrecognized command" in lines:
|
if "Unrecognized command" in lines:
|
||||||
raise Exception("Bad Credentials")
|
raise Exception("Bad Credentials")
|
||||||
child.sendline("exit")
|
child.sendline("exit")
|
||||||
#child.expect( ">")
|
# child.expect( ">")
|
||||||
#child.sendline("logout")
|
# child.sendline("logout")
|
||||||
for line in lines.split("\n")[1:-1]:
|
for line in lines.split("\n")[1:-1]:
|
||||||
open(filename,"a").write(line.strip()+"\n")
|
open(filename, "a").write(line.strip() + "\n")
|
||||||
|
|
||||||
|
|
||||||
def switch_dlink_DGS1210(self, filename):
|
def switch_dlink_DGS1210(self, filename):
|
||||||
login_data = {'Login' : self.switch_user, 'Password' : self.switch_password, 'sellanId' : 0, 'sellan' : 0, 'lang_seqid' : 1}
|
login_data = {"Login": self.switch_user, "Password": self.switch_password, "sellanId": 0, "sellan": 0, "lang_seqid": 1}
|
||||||
resp = requests.post('http://%s/form/formLoginApply' % self.switch_ip, data=login_data, headers={"Referer":'http://%s/www/login.html' % self.switch_ip})
|
resp = requests.post(
|
||||||
|
"http://%s/form/formLoginApply" % self.switch_ip,
|
||||||
|
data=login_data,
|
||||||
|
headers={"Referer": "http://%s/www/login.html" % self.switch_ip},
|
||||||
|
)
|
||||||
if "Wrong password" in resp.text:
|
if "Wrong password" in resp.text:
|
||||||
raise Exception("Wrong password")
|
raise Exception("Wrong password")
|
||||||
resp = requests.post("http://%s/BinFile/config.bin" % self.switch_ip, headers={"Referer":'http://%s/www/iss/013_download_cfg.html' % self.switch_ip})
|
resp = requests.post(
|
||||||
with open(filename, 'w') as f:
|
"http://%s/BinFile/config.bin" % self.switch_ip, headers={"Referer": "http://%s/www/iss/013_download_cfg.html" % self.switch_ip}
|
||||||
|
)
|
||||||
|
with open(filename, "w") as f:
|
||||||
f.write(resp.content)
|
f.write(resp.content)
|
||||||
|
|
||||||
def switch_dlink_DGS1510(self, filename):
|
def switch_dlink_DGS1510(self, filename):
|
||||||
@@ -181,12 +188,12 @@ class backup_switch(backup_generic):
|
|||||||
except:
|
except:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
child = pexpect.spawn('telnet ' + self.switch_ip)
|
child = pexpect.spawn("telnet " + self.switch_ip)
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
if self.switch_user:
|
if self.switch_user:
|
||||||
child.sendline(self.switch_user)
|
child.sendline(self.switch_user)
|
||||||
child.expect('Password:')
|
child.expect("Password:")
|
||||||
child.sendline(self.switch_password + '\r')
|
child.sendline(self.switch_password + "\r")
|
||||||
try:
|
try:
|
||||||
child.expect("#")
|
child.expect("#")
|
||||||
except:
|
except:
|
||||||
@@ -195,68 +202,66 @@ class backup_switch(backup_generic):
|
|||||||
child.expect("#")
|
child.expect("#")
|
||||||
child.sendline("show run\r")
|
child.sendline("show run\r")
|
||||||
child.logfile_read = open(filename, "a")
|
child.logfile_read = open(filename, "a")
|
||||||
child.expect('End of configuration file')
|
child.expect("End of configuration file")
|
||||||
child.expect('#--')
|
child.expect("#--")
|
||||||
child.expect("#")
|
child.expect("#")
|
||||||
child.close()
|
child.close()
|
||||||
myre = re.compile("#--+")
|
myre = re.compile(r"#--+")
|
||||||
config = myre.split(open(filename).read())[2]
|
config = myre.split(open(filename).read())[2]
|
||||||
with open(filename,'w') as f:
|
with open(filename, "w") as f:
|
||||||
f.write(config)
|
f.write(config)
|
||||||
|
|
||||||
|
def do_backup(self, stats):
|
||||||
def do_backup(self,stats):
|
|
||||||
try:
|
try:
|
||||||
dest_filename = os.path.join(self.backup_dir,"%s-%s" % (self.backup_name,self.backup_start_date))
|
dest_filename = os.path.join(self.backup_dir, "%s-%s" % (self.backup_name, self.backup_start_date))
|
||||||
|
|
||||||
options = []
|
# options = []
|
||||||
options_params = " ".join(options)
|
# options_params = " ".join(options)
|
||||||
if "LINKSYS-SRW2024" == self.switch_type:
|
if "LINKSYS-SRW2024" == self.switch_type:
|
||||||
dest_filename += '.txt'
|
dest_filename += ".txt"
|
||||||
self.switch_linksys_SRW2024(dest_filename)
|
self.switch_linksys_SRW2024(dest_filename)
|
||||||
elif self.switch_type in [ "CISCO", ]:
|
elif self.switch_type in [
|
||||||
dest_filename += '.txt'
|
"CISCO",
|
||||||
|
]:
|
||||||
|
dest_filename += ".txt"
|
||||||
self.switch_cisco(dest_filename)
|
self.switch_cisco(dest_filename)
|
||||||
elif self.switch_type in [ "HP-PROCURVE-4104GL", "HP-PROCURVE-2524" ]:
|
elif self.switch_type in ["HP-PROCURVE-4104GL", "HP-PROCURVE-2524"]:
|
||||||
dest_filename += '.txt'
|
dest_filename += ".txt"
|
||||||
self.switch_hp(dest_filename)
|
self.switch_hp(dest_filename)
|
||||||
elif "DLINK-DGS1210" == self.switch_type:
|
elif "DLINK-DGS1210" == self.switch_type:
|
||||||
dest_filename += '.bin'
|
dest_filename += ".bin"
|
||||||
self.switch_dlink_DGS1210(dest_filename)
|
self.switch_dlink_DGS1210(dest_filename)
|
||||||
elif "DLINK-DGS1510" == self.switch_type:
|
elif "DLINK-DGS1510" == self.switch_type:
|
||||||
dest_filename += '.cfg'
|
dest_filename += ".cfg"
|
||||||
self.switch_dlink_DGS1510(dest_filename)
|
self.switch_dlink_DGS1510(dest_filename)
|
||||||
else:
|
else:
|
||||||
raise Exception("Unknown Switch type")
|
raise Exception("Unknown Switch type")
|
||||||
|
|
||||||
stats['total_files_count']=1
|
stats["total_files_count"] = 1
|
||||||
stats['written_files_count']=1
|
stats["written_files_count"] = 1
|
||||||
stats['total_bytes']= os.stat(dest_filename).st_size
|
stats["total_bytes"] = os.stat(dest_filename).st_size
|
||||||
stats['written_bytes'] = stats['total_bytes']
|
stats["written_bytes"] = stats["total_bytes"]
|
||||||
stats['backup_location'] = dest_filename
|
stats["backup_location"] = dest_filename
|
||||||
stats['status']='OK'
|
stats["status"] = "OK"
|
||||||
stats['log']='Switch backup from %s OK, %d bytes written' % (self.server_name,stats['written_bytes'])
|
stats["log"] = "Switch backup from %s OK, %d bytes written" % (self.server_name, stats["written_bytes"])
|
||||||
|
|
||||||
|
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
stats['status']='ERROR'
|
stats["status"] = "ERROR"
|
||||||
stats['log']=str(e)
|
stats["log"] = str(e)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
register_driver(backup_switch)
|
register_driver(backup_switch)
|
||||||
|
|
||||||
if __name__=='__main__':
|
if __name__ == "__main__":
|
||||||
logger = logging.getLogger('tisbackup')
|
logger = logging.getLogger("tisbackup")
|
||||||
logger.setLevel(logging.DEBUG)
|
logger.setLevel(logging.DEBUG)
|
||||||
formatter = logging.Formatter('%(asctime)s %(levelname)s %(message)s')
|
formatter = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
|
||||||
handler = logging.StreamHandler()
|
handler = logging.StreamHandler()
|
||||||
handler.setFormatter(formatter)
|
handler.setFormatter(formatter)
|
||||||
logger.addHandler(handler)
|
logger.addHandler(handler)
|
||||||
|
|
||||||
cp = ConfigParser()
|
cp = ConfigParser()
|
||||||
cp.read('/opt/tisbackup/configtest.ini')
|
cp.read("/opt/tisbackup/configtest.ini")
|
||||||
b = backup_xva()
|
b = backup_xva()
|
||||||
b.read_config(cp)
|
b.read_config(cp)
|
||||||
|
|
||||||
Executable → Regular
+91
-103
@@ -18,62 +18,60 @@
|
|||||||
#
|
#
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
from .common import *
|
|
||||||
import pyVmomi
|
|
||||||
from pyVmomi import vim
|
|
||||||
from pyVmomi import vmodl
|
|
||||||
from pyVim.connect import SmartConnect, Disconnect
|
|
||||||
|
|
||||||
from datetime import datetime, date, timedelta
|
|
||||||
import atexit
|
import atexit
|
||||||
import getpass
|
import getpass
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
|
||||||
|
import pyVmomi
|
||||||
import requests
|
import requests
|
||||||
|
from pyVim.connect import Disconnect, SmartConnect
|
||||||
|
from pyVmomi import vim, vmodl
|
||||||
|
|
||||||
# Disable HTTPS verification warnings.
|
# Disable HTTPS verification warnings.
|
||||||
from requests.packages import urllib3
|
from requests.packages import urllib3
|
||||||
|
|
||||||
|
from libtisbackup import *
|
||||||
|
|
||||||
urllib3.disable_warnings()
|
urllib3.disable_warnings()
|
||||||
import os
|
import os
|
||||||
import time
|
|
||||||
import tarfile
|
|
||||||
import re
|
import re
|
||||||
|
import tarfile
|
||||||
|
import time
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
from stat import *
|
from stat import *
|
||||||
|
|
||||||
|
|
||||||
class backup_vmdk(backup_generic):
|
class backup_vmdk(backup_generic):
|
||||||
type = 'esx-vmdk'
|
type = "esx-vmdk"
|
||||||
|
|
||||||
required_params = backup_generic.required_params + ['esxhost','password_file','server_name']
|
required_params = backup_generic.required_params + ["esxhost", "password_file", "server_name"]
|
||||||
optional_params = backup_generic.optional_params + ['esx_port', 'prefix_clone', 'create_ovafile', 'halt_vm']
|
optional_params = backup_generic.optional_params + ["esx_port", "prefix_clone", "create_ovafile", "halt_vm"]
|
||||||
|
|
||||||
esx_port = 443
|
esx_port = 443
|
||||||
prefix_clone = "clone-"
|
prefix_clone = "clone-"
|
||||||
create_ovafile = "no"
|
create_ovafile = "no"
|
||||||
halt_vm = "no"
|
halt_vm = "no"
|
||||||
|
|
||||||
def make_compatible_cookie(self,client_cookie):
|
def make_compatible_cookie(self, client_cookie):
|
||||||
cookie_name = client_cookie.split("=", 1)[0]
|
cookie_name = client_cookie.split("=", 1)[0]
|
||||||
cookie_value = client_cookie.split("=", 1)[1].split(";", 1)[0]
|
cookie_value = client_cookie.split("=", 1)[1].split(";", 1)[0]
|
||||||
cookie_path = client_cookie.split("=", 1)[1].split(";", 1)[1].split(
|
cookie_path = client_cookie.split("=", 1)[1].split(";", 1)[1].split(";", 1)[0].lstrip()
|
||||||
";", 1)[0].lstrip()
|
|
||||||
cookie_text = " " + cookie_value + "; $" + cookie_path
|
cookie_text = " " + cookie_value + "; $" + cookie_path
|
||||||
# Make a cookie
|
# Make a cookie
|
||||||
cookie = dict()
|
cookie = dict()
|
||||||
cookie[cookie_name] = cookie_text
|
cookie[cookie_name] = cookie_text
|
||||||
return cookie
|
return cookie
|
||||||
|
|
||||||
|
def download_file(self, url, local_filename, cookie, headers):
|
||||||
def download_file(self,url, local_filename, cookie, headers):
|
r = requests.get(url, stream=True, headers=headers, cookies=cookie, verify=False)
|
||||||
r = requests.get(url, stream=True, headers=headers,cookies=cookie,verify=False)
|
with open(local_filename, "wb") as f:
|
||||||
with open(local_filename, 'wb') as f:
|
for chunk in r.iter_content(chunk_size=1024 * 1024 * 64):
|
||||||
for chunk in r.iter_content(chunk_size=1024*1024*64):
|
|
||||||
if chunk:
|
if chunk:
|
||||||
f.write(chunk)
|
f.write(chunk)
|
||||||
f.flush()
|
f.flush()
|
||||||
return local_filename
|
return local_filename
|
||||||
|
|
||||||
|
def export_vmdks(self, vm):
|
||||||
def export_vmdks(self,vm):
|
|
||||||
HttpNfcLease = vm.ExportVm()
|
HttpNfcLease = vm.ExportVm()
|
||||||
try:
|
try:
|
||||||
infos = HttpNfcLease.info
|
infos = HttpNfcLease.info
|
||||||
@@ -82,153 +80,147 @@ class backup_vmdk(backup_generic):
|
|||||||
for device_url in device_urls:
|
for device_url in device_urls:
|
||||||
deviceId = device_url.key
|
deviceId = device_url.key
|
||||||
deviceUrlStr = device_url.url
|
deviceUrlStr = device_url.url
|
||||||
diskFileName = vm.name.replace(self.prefix_clone,'') + "-" + device_url.targetId
|
diskFileName = vm.name.replace(self.prefix_clone, "") + "-" + device_url.targetId
|
||||||
diskUrlStr = deviceUrlStr.replace("*", self.esxhost)
|
diskUrlStr = deviceUrlStr.replace("*", self.esxhost)
|
||||||
diskLocalPath = './' + diskFileName
|
# diskLocalPath = './' + diskFileName
|
||||||
|
|
||||||
cookie = self.make_compatible_cookie(si._stub.cookie)
|
cookie = self.make_compatible_cookie(si._stub.cookie)
|
||||||
headers = {'Content-Type': 'application/octet-stream'}
|
headers = {"Content-Type": "application/octet-stream"}
|
||||||
self.logger.debug("[%s] exporting disk: %s" %(self.server_name,diskFileName))
|
self.logger.debug("[%s] exporting disk: %s" % (self.server_name, diskFileName))
|
||||||
|
|
||||||
self.download_file(diskUrlStr, diskFileName, cookie, headers)
|
self.download_file(diskUrlStr, diskFileName, cookie, headers)
|
||||||
vmdks.append({"filename":diskFileName,"id":deviceId})
|
vmdks.append({"filename": diskFileName, "id": deviceId})
|
||||||
finally:
|
finally:
|
||||||
HttpNfcLease.Complete()
|
HttpNfcLease.Complete()
|
||||||
return vmdks
|
return vmdks
|
||||||
|
|
||||||
|
def create_ovf(self, vm, vmdks):
|
||||||
def create_ovf(self,vm,vmdks):
|
|
||||||
ovfDescParams = vim.OvfManager.CreateDescriptorParams()
|
ovfDescParams = vim.OvfManager.CreateDescriptorParams()
|
||||||
ovf = si.content.ovfManager.CreateDescriptor(vm, ovfDescParams)
|
ovf = si.content.ovfManager.CreateDescriptor(vm, ovfDescParams)
|
||||||
root = ET.fromstring(ovf.ovfDescriptor)
|
root = ET.fromstring(ovf.ovfDescriptor)
|
||||||
new_id = list(root[0][1].attrib.values())[0][1:3]
|
new_id = list(root[0][1].attrib.values())[0][1:3]
|
||||||
ovfFiles = []
|
ovfFiles = []
|
||||||
for vmdk in vmdks:
|
for vmdk in vmdks:
|
||||||
old_id = vmdk['id'][1:3]
|
old_id = vmdk["id"][1:3]
|
||||||
id = vmdk['id'].replace(old_id,new_id)
|
id = vmdk["id"].replace(old_id, new_id)
|
||||||
ovfFiles.append(vim.OvfManager.OvfFile(size=os.path.getsize(vmdk['filename']), path=vmdk['filename'], deviceId=id))
|
ovfFiles.append(vim.OvfManager.OvfFile(size=os.path.getsize(vmdk["filename"]), path=vmdk["filename"], deviceId=id))
|
||||||
|
|
||||||
ovfDescParams = vim.OvfManager.CreateDescriptorParams()
|
ovfDescParams = vim.OvfManager.CreateDescriptorParams()
|
||||||
ovfDescParams.ovfFiles = ovfFiles;
|
ovfDescParams.ovfFiles = ovfFiles
|
||||||
|
|
||||||
ovf = si.content.ovfManager.CreateDescriptor(vm, ovfDescParams)
|
ovf = si.content.ovfManager.CreateDescriptor(vm, ovfDescParams)
|
||||||
ovf_filename = vm.name+".ovf"
|
ovf_filename = vm.name + ".ovf"
|
||||||
self.logger.debug("[%s] creating ovf file: %s" %(self.server_name,ovf_filename))
|
self.logger.debug("[%s] creating ovf file: %s" % (self.server_name, ovf_filename))
|
||||||
|
|
||||||
with open(ovf_filename, "w") as f:
|
with open(ovf_filename, "w") as f:
|
||||||
f.write(ovf.ovfDescriptor)
|
f.write(ovf.ovfDescriptor)
|
||||||
return ovf_filename
|
return ovf_filename
|
||||||
|
|
||||||
def create_ova(self,vm, vmdks, ovf_filename):
|
def create_ova(self, vm, vmdks, ovf_filename):
|
||||||
ova_filename = vm.name+".ova"
|
ova_filename = vm.name + ".ova"
|
||||||
vmdks.insert(0,{"filename":ovf_filename,"id":"false"})
|
vmdks.insert(0, {"filename": ovf_filename, "id": "false"})
|
||||||
self.logger.debug("[%s] creating ova file: %s" %(self.server_name,ova_filename))
|
self.logger.debug("[%s] creating ova file: %s" % (self.server_name, ova_filename))
|
||||||
with tarfile.open(ova_filename, "w") as tar:
|
with tarfile.open(ova_filename, "w") as tar:
|
||||||
for vmdk in vmdks:
|
for vmdk in vmdks:
|
||||||
tar.add(vmdk['filename'])
|
tar.add(vmdk["filename"])
|
||||||
os.unlink(vmdk['filename'])
|
os.unlink(vmdk["filename"])
|
||||||
return ova_filename
|
return ova_filename
|
||||||
|
|
||||||
def clone_vm(self,vm):
|
def clone_vm(self, vm):
|
||||||
task = self.wait_task(vm.CreateSnapshot_Task(name="backup",description="Automatic backup "+datetime.now().strftime("%Y-%m-%d %H:%M:%s"),memory=False,quiesce=True))
|
task = self.wait_task(
|
||||||
snapshot=task.info.result
|
vm.CreateSnapshot_Task(
|
||||||
|
name="backup", description="Automatic backup " + datetime.now().strftime("%Y-%m-%d %H:%M:%s"), memory=False, quiesce=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
snapshot = task.info.result
|
||||||
prefix_vmclone = self.prefix_clone
|
prefix_vmclone = self.prefix_clone
|
||||||
clone_name = prefix_vmclone + vm.name
|
clone_name = prefix_vmclone + vm.name
|
||||||
datastore = '[%s]' % vm.datastore[0].name
|
datastore = "[%s]" % vm.datastore[0].name
|
||||||
|
|
||||||
|
vmx_file = vim.vm.FileInfo(logDirectory=None, snapshotDirectory=None, suspendDirectory=None, vmPathName=datastore)
|
||||||
|
|
||||||
|
config = vim.vm.ConfigSpec(
|
||||||
vmx_file = vim.vm.FileInfo(logDirectory=None,
|
name=clone_name, memoryMB=vm.summary.config.memorySizeMB, numCPUs=vm.summary.config.numCpu, files=vmx_file
|
||||||
snapshotDirectory=None,
|
)
|
||||||
suspendDirectory=None,
|
|
||||||
vmPathName=datastore)
|
|
||||||
|
|
||||||
config = vim.vm.ConfigSpec(name=clone_name, memoryMB=vm.summary.config.memorySizeMB, numCPUs=vm.summary.config.numCpu, files=vmx_file)
|
|
||||||
|
|
||||||
hosts = datacenter.hostFolder.childEntity
|
hosts = datacenter.hostFolder.childEntity
|
||||||
resource_pool = hosts[0].resourcePool
|
resource_pool = hosts[0].resourcePool
|
||||||
|
|
||||||
self.wait_task(vmFolder.CreateVM_Task(config=config,pool=resource_pool))
|
self.wait_task(vmFolder.CreateVM_Task(config=config, pool=resource_pool))
|
||||||
|
|
||||||
new_vm = [x for x in vmFolder.childEntity if x.name == clone_name][0]
|
new_vm = [x for x in vmFolder.childEntity if x.name == clone_name][0]
|
||||||
|
|
||||||
controller = vim.vm.device.VirtualDeviceSpec()
|
controller = vim.vm.device.VirtualDeviceSpec()
|
||||||
controller.operation = vim.vm.device.VirtualDeviceSpec.Operation.add
|
controller.operation = vim.vm.device.VirtualDeviceSpec.Operation.add
|
||||||
controller.device = vim.vm.device.VirtualLsiLogicController(busNumber=0,sharedBus='noSharing')
|
controller.device = vim.vm.device.VirtualLsiLogicController(busNumber=0, sharedBus="noSharing")
|
||||||
controller.device.key = 0
|
controller.device.key = 0
|
||||||
i=0
|
i = 0
|
||||||
|
|
||||||
vm_devices = []
|
vm_devices = []
|
||||||
clone_folder = "%s/" % "/".join(new_vm.summary.config.vmPathName.split('/')[:-1])
|
clone_folder = "%s/" % "/".join(new_vm.summary.config.vmPathName.split("/")[:-1])
|
||||||
for device in vm.config.hardware.device:
|
for device in vm.config.hardware.device:
|
||||||
if device.__class__.__name__ == 'vim.vm.device.VirtualDisk':
|
if device.__class__.__name__ == "vim.vm.device.VirtualDisk":
|
||||||
cur_vers = int(re.findall(r'\d{3,6}', device.backing.fileName)[0])
|
cur_vers = int(re.findall(r"\d{3,6}", device.backing.fileName)[0])
|
||||||
|
|
||||||
if cur_vers == 1:
|
if cur_vers == 1:
|
||||||
source = device.backing.fileName.replace('-000001','')
|
source = device.backing.fileName.replace("-000001", "")
|
||||||
else:
|
else:
|
||||||
source = device.backing.fileName.replace('%d.' % cur_vers,'%d.' % ( cur_vers - 1 ))
|
source = device.backing.fileName.replace("%d." % cur_vers, "%d." % (cur_vers - 1))
|
||||||
|
|
||||||
|
dest = clone_folder + source.split("/")[-1]
|
||||||
dest = clone_folder+source.split('/')[-1]
|
disk_spec = vim.VirtualDiskManager.VirtualDiskSpec(diskType="sparseMonolithic", adapterType="ide")
|
||||||
disk_spec = vim.VirtualDiskManager.VirtualDiskSpec(diskType="sparseMonolithic",adapterType="ide")
|
self.wait_task(si.content.virtualDiskManager.CopyVirtualDisk_Task(sourceName=source, destName=dest, destSpec=disk_spec))
|
||||||
self.wait_task(si.content.virtualDiskManager.CopyVirtualDisk_Task(sourceName=source,destName=dest,destSpec=disk_spec))
|
# self.wait_task(si.content.virtualDiskManager.ShrinkVirtualDisk_Task(dest))
|
||||||
# self.wait_task(si.content.virtualDiskManager.ShrinkVirtualDisk_Task(dest))
|
|
||||||
|
|
||||||
diskfileBacking = vim.vm.device.VirtualDisk.FlatVer2BackingInfo()
|
diskfileBacking = vim.vm.device.VirtualDisk.FlatVer2BackingInfo()
|
||||||
diskfileBacking.fileName = dest
|
diskfileBacking.fileName = dest
|
||||||
diskfileBacking.diskMode = "persistent"
|
diskfileBacking.diskMode = "persistent"
|
||||||
vdisk_spec = vim.vm.device.VirtualDeviceSpec()
|
vdisk_spec = vim.vm.device.VirtualDeviceSpec()
|
||||||
vdisk_spec.operation = vim.vm.device.VirtualDeviceSpec.Operation.add
|
vdisk_spec.operation = vim.vm.device.VirtualDeviceSpec.Operation.add
|
||||||
vdisk_spec.device = vim.vm.device.VirtualDisk(capacityInKB=10000 ,controllerKey=controller.device.key)
|
vdisk_spec.device = vim.vm.device.VirtualDisk(capacityInKB=10000, controllerKey=controller.device.key)
|
||||||
vdisk_spec.device.key = 0
|
vdisk_spec.device.key = 0
|
||||||
vdisk_spec.device.backing = diskfileBacking
|
vdisk_spec.device.backing = diskfileBacking
|
||||||
vdisk_spec.device.unitNumber = i
|
vdisk_spec.device.unitNumber = i
|
||||||
vm_devices.append(vdisk_spec)
|
vm_devices.append(vdisk_spec)
|
||||||
i+=1
|
i += 1
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
vm_devices.append(controller)
|
vm_devices.append(controller)
|
||||||
|
|
||||||
config.deviceChange=vm_devices
|
config.deviceChange = vm_devices
|
||||||
self.wait_task(new_vm.ReconfigVM_Task(config))
|
self.wait_task(new_vm.ReconfigVM_Task(config))
|
||||||
self.wait_task(snapshot.RemoveSnapshot_Task(removeChildren=True))
|
self.wait_task(snapshot.RemoveSnapshot_Task(removeChildren=True))
|
||||||
return new_vm
|
return new_vm
|
||||||
def wait_task(self,task):
|
|
||||||
|
def wait_task(self, task):
|
||||||
while task.info.state in ["queued", "running"]:
|
while task.info.state in ["queued", "running"]:
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
self.logger.debug("[%s] %s",self.server_name,task.info.descriptionId)
|
self.logger.debug("[%s] %s", self.server_name, task.info.descriptionId)
|
||||||
return task
|
return task
|
||||||
|
|
||||||
|
def do_backup(self, stats):
|
||||||
|
|
||||||
|
|
||||||
def do_backup(self,stats):
|
|
||||||
try:
|
try:
|
||||||
dest_dir = os.path.join(self.backup_dir,"%s" % self.backup_start_date)
|
dest_dir = os.path.join(self.backup_dir, "%s" % self.backup_start_date)
|
||||||
if not os.path.isdir(dest_dir):
|
if not os.path.isdir(dest_dir):
|
||||||
if not self.dry_run:
|
if not self.dry_run:
|
||||||
os.makedirs(dest_dir)
|
os.makedirs(dest_dir)
|
||||||
else:
|
else:
|
||||||
print('mkdir "%s"' % dest_dir)
|
print('mkdir "%s"' % dest_dir)
|
||||||
else:
|
else:
|
||||||
raise Exception('backup destination directory already exists : %s' % dest_dir)
|
raise Exception("backup destination directory already exists : %s" % dest_dir)
|
||||||
os.chdir(dest_dir)
|
os.chdir(dest_dir)
|
||||||
user_esx, password_esx, null = open(self.password_file).read().split('\n')
|
user_esx, password_esx, null = open(self.password_file).read().split("\n")
|
||||||
|
|
||||||
global si
|
global si
|
||||||
si = SmartConnect(host=self.esxhost,user=user_esx,pwd=password_esx,port=self.esx_port)
|
si = SmartConnect(host=self.esxhost, user=user_esx, pwd=password_esx, port=self.esx_port)
|
||||||
|
|
||||||
if not si:
|
if not si:
|
||||||
raise Exception("Could not connect to the specified host using specified "
|
raise Exception("Could not connect to the specified host using specified " "username and password")
|
||||||
"username and password")
|
|
||||||
|
|
||||||
atexit.register(Disconnect, si)
|
atexit.register(Disconnect, si)
|
||||||
|
|
||||||
content = si.RetrieveContent()
|
content = si.RetrieveContent()
|
||||||
for child in content.rootFolder.childEntity:
|
for child in content.rootFolder.childEntity:
|
||||||
if hasattr(child, 'vmFolder'):
|
if hasattr(child, "vmFolder"):
|
||||||
global vmFolder, datacenter
|
global vmFolder, datacenter
|
||||||
datacenter = child
|
datacenter = child
|
||||||
vmFolder = datacenter.vmFolder
|
vmFolder = datacenter.vmFolder
|
||||||
@@ -240,7 +232,7 @@ class backup_vmdk(backup_generic):
|
|||||||
vm.ShutdownGuest()
|
vm.ShutdownGuest()
|
||||||
vm_is_off = True
|
vm_is_off = True
|
||||||
|
|
||||||
if vm_is_off:
|
if vm_is_off:
|
||||||
vmdks = self.export_vmdks(vm)
|
vmdks = self.export_vmdks(vm)
|
||||||
ovf_filename = self.create_ovf(vm, vmdks)
|
ovf_filename = self.create_ovf(vm, vmdks)
|
||||||
else:
|
else:
|
||||||
@@ -250,33 +242,29 @@ class backup_vmdk(backup_generic):
|
|||||||
self.wait_task(new_vm.Destroy_Task())
|
self.wait_task(new_vm.Destroy_Task())
|
||||||
|
|
||||||
if str2bool(self.create_ovafile):
|
if str2bool(self.create_ovafile):
|
||||||
ova_filename = self.create_ova(vm, vmdks, ovf_filename)
|
ova_filename = self.create_ova(vm, vmdks, ovf_filename) # noqa : F841
|
||||||
|
|
||||||
if str2bool(self.halt_vm):
|
if str2bool(self.halt_vm):
|
||||||
vm.PowerOnVM()
|
vm.PowerOnVM()
|
||||||
|
|
||||||
|
|
||||||
if os.path.exists(dest_dir):
|
if os.path.exists(dest_dir):
|
||||||
for file in os.listdir(dest_dir):
|
for file in os.listdir(dest_dir):
|
||||||
stats['written_bytes'] += os.stat(file)[ST_SIZE]
|
stats["written_bytes"] += os.stat(file)[ST_SIZE]
|
||||||
stats['total_files_count'] += 1
|
stats["total_files_count"] += 1
|
||||||
stats['written_files_count'] += 1
|
stats["written_files_count"] += 1
|
||||||
stats['total_bytes'] = stats['written_bytes']
|
stats["total_bytes"] = stats["written_bytes"]
|
||||||
else:
|
else:
|
||||||
stats['written_bytes'] = 0
|
stats["written_bytes"] = 0
|
||||||
|
|
||||||
stats['backup_location'] = dest_dir
|
stats["backup_location"] = dest_dir
|
||||||
|
|
||||||
stats['log']='XVA backup from %s OK, %d bytes written' % (self.server_name,stats['written_bytes'])
|
|
||||||
stats['status']='OK'
|
|
||||||
|
|
||||||
|
stats["log"] = "XVA backup from %s OK, %d bytes written" % (self.server_name, stats["written_bytes"])
|
||||||
|
stats["status"] = "OK"
|
||||||
|
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
stats['status']='ERROR'
|
stats["status"] = "ERROR"
|
||||||
stats['log']=str(e)
|
stats["log"] = str(e)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
register_driver(backup_vmdk)
|
register_driver(backup_vmdk)
|
||||||
|
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# This file is part of TISBackup
|
||||||
|
#
|
||||||
|
# TISBackup is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# TISBackup is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with TISBackup. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
import paramiko
|
||||||
|
|
||||||
|
from libtisbackup import *
|
||||||
|
|
||||||
|
|
||||||
|
class backup_xcp_metadata(backup_generic):
|
||||||
|
"""Backup metatdata of a xcp pool using xe pool-dump-database"""
|
||||||
|
|
||||||
|
type = "xcp-dump-metadata"
|
||||||
|
required_params = ["type", "server_name", "private_key", "backup_name"]
|
||||||
|
|
||||||
|
def do_backup(self, stats):
|
||||||
|
self.logger.debug("[%s] Connecting to %s with user root and key %s", self.backup_name, self.server_name, self.private_key)
|
||||||
|
|
||||||
|
t = datetime.datetime.now()
|
||||||
|
backup_start_date = t.strftime("%Y%m%d-%Hh%Mm%S")
|
||||||
|
|
||||||
|
# dump pool medatadata
|
||||||
|
localpath = os.path.join(self.backup_dir, "xcp_metadata-" + backup_start_date + ".dump")
|
||||||
|
stats["status"] = "Dumping"
|
||||||
|
if not self.dry_run:
|
||||||
|
cmd = "/opt/xensource/bin/xe pool-dump-database file-name="
|
||||||
|
self.logger.debug("[%s] Dump XCP Metadata : %s", self.backup_name, cmd)
|
||||||
|
(error_code, output) = ssh_exec(cmd, server_name=self.server_name, private_key=self.private_key, remote_user="root")
|
||||||
|
|
||||||
|
with open(localpath, "w") as f:
|
||||||
|
f.write(output)
|
||||||
|
|
||||||
|
# zip the file
|
||||||
|
stats["status"] = "Zipping"
|
||||||
|
cmd = "gzip %s " % localpath
|
||||||
|
self.logger.debug("[%s] Compress backup : %s", self.backup_name, cmd)
|
||||||
|
if not self.dry_run:
|
||||||
|
call_external_process(cmd)
|
||||||
|
localpath += ".gz"
|
||||||
|
if not self.dry_run:
|
||||||
|
stats["total_files_count"] = 1
|
||||||
|
stats["written_files_count"] = 1
|
||||||
|
stats["total_bytes"] = os.stat(localpath).st_size
|
||||||
|
stats["written_bytes"] = os.stat(localpath).st_size
|
||||||
|
stats["log"] = "gzip dump of DB %s:%s (%d bytes) to %s" % (self.server_name, "xcp metadata dump", stats["written_bytes"], localpath)
|
||||||
|
stats["backup_location"] = localpath
|
||||||
|
stats["status"] = "OK"
|
||||||
|
|
||||||
|
def register_existingbackups(self):
|
||||||
|
"""scan metatdata backup files and insert stats in database"""
|
||||||
|
|
||||||
|
registered = [
|
||||||
|
b["backup_location"]
|
||||||
|
for b in self.dbstat.query("select distinct backup_location from stats where backup_name=?", (self.backup_name,))
|
||||||
|
]
|
||||||
|
|
||||||
|
filelist = os.listdir(self.backup_dir)
|
||||||
|
filelist.sort()
|
||||||
|
p = re.compile(r"^%s-(?P<date>\d{8,8}-\d{2,2}h\d{2,2}m\d{2,2}).dump.gz$" % self.server_name)
|
||||||
|
for item in filelist:
|
||||||
|
sr = p.match(item)
|
||||||
|
if sr:
|
||||||
|
file_name = os.path.join(self.backup_dir, item)
|
||||||
|
start = datetime.datetime.strptime(sr.groups()[0], "%Y%m%d-%Hh%Mm%S").isoformat()
|
||||||
|
if file_name not in registered:
|
||||||
|
self.logger.info("Registering %s from %s", file_name, fileisodate(file_name))
|
||||||
|
size_bytes = int(os.popen('du -sb "%s"' % file_name).read().split("\t")[0])
|
||||||
|
self.logger.debug(" Size in bytes : %i", size_bytes)
|
||||||
|
if not self.dry_run:
|
||||||
|
self.dbstat.add(
|
||||||
|
self.backup_name,
|
||||||
|
self.server_name,
|
||||||
|
"",
|
||||||
|
backup_start=start,
|
||||||
|
backup_end=fileisodate(file_name),
|
||||||
|
status="OK",
|
||||||
|
total_bytes=size_bytes,
|
||||||
|
backup_location=file_name,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.logger.info("Skipping %s from %s, already registered", file_name, fileisodate(file_name))
|
||||||
|
|
||||||
|
|
||||||
|
register_driver(backup_xcp_metadata)
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# This file is part of TISBackup
|
||||||
|
#
|
||||||
|
# TISBackup is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# TISBackup is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with TISBackup. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from stat import *
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from . import XenAPI
|
||||||
|
from libtisbackup import *
|
||||||
|
|
||||||
|
if hasattr(ssl, "_create_unverified_context"):
|
||||||
|
ssl._create_default_https_context = ssl._create_unverified_context
|
||||||
|
|
||||||
|
|
||||||
|
class backup_xva(backup_generic):
|
||||||
|
"""Backup a VM running on a XCP server as a XVA file (requires xe tools and XenAPI)"""
|
||||||
|
|
||||||
|
type = "xen-xva"
|
||||||
|
|
||||||
|
required_params = backup_generic.required_params + ["xcphost", "password_file", "server_name"]
|
||||||
|
optional_params = backup_generic.optional_params + [
|
||||||
|
"enable_https",
|
||||||
|
"halt_vm",
|
||||||
|
"verify_export",
|
||||||
|
"reuse_snapshot",
|
||||||
|
"ignore_proxies",
|
||||||
|
"use_compression",
|
||||||
|
]
|
||||||
|
|
||||||
|
enable_https = "no"
|
||||||
|
halt_vm = "no"
|
||||||
|
verify_export = "no"
|
||||||
|
reuse_snapshot = "no"
|
||||||
|
ignore_proxies = "yes"
|
||||||
|
use_compression = "true"
|
||||||
|
|
||||||
|
if str2bool(ignore_proxies):
|
||||||
|
os.environ["http_proxy"] = ""
|
||||||
|
os.environ["https_proxy"] = ""
|
||||||
|
|
||||||
|
def verify_export_xva(self, filename):
|
||||||
|
self.logger.debug("[%s] Verify xva export integrity", self.server_name)
|
||||||
|
tar = tarfile.open(filename)
|
||||||
|
members = tar.getmembers()
|
||||||
|
for tarinfo in members:
|
||||||
|
if re.search("^[0-9]*$", os.path.basename(tarinfo.name)):
|
||||||
|
sha1sum = hashlib.sha1(tar.extractfile(tarinfo).read()).hexdigest()
|
||||||
|
sha1sum2 = tar.extractfile(tarinfo.name + ".checksum").read()
|
||||||
|
if not sha1sum == sha1sum2:
|
||||||
|
raise Exception("File corrupt")
|
||||||
|
tar.close()
|
||||||
|
|
||||||
|
def export_xva(self, vdi_name, filename, halt_vm, dry_run, enable_https=True, reuse_snapshot="no"):
|
||||||
|
user_xen, password_xen, null = open(self.password_file).read().split("\n")
|
||||||
|
session = XenAPI.Session("https://" + self.xcphost)
|
||||||
|
try:
|
||||||
|
session.login_with_password(user_xen, password_xen)
|
||||||
|
except XenAPI.Failure as error:
|
||||||
|
msg, ip = error.details
|
||||||
|
|
||||||
|
if msg == "HOST_IS_SLAVE":
|
||||||
|
xcphost = ip
|
||||||
|
session = XenAPI.Session("https://" + xcphost)
|
||||||
|
session.login_with_password(user_xen, password_xen)
|
||||||
|
|
||||||
|
if not session.xenapi.VM.get_by_name_label(vdi_name):
|
||||||
|
return "bad VM name: %s" % vdi_name
|
||||||
|
|
||||||
|
vm = session.xenapi.VM.get_by_name_label(vdi_name)[0]
|
||||||
|
status_vm = session.xenapi.VM.get_power_state(vm)
|
||||||
|
|
||||||
|
self.logger.debug("[%s] Check if previous fail backups exist", vdi_name)
|
||||||
|
backups_fail = [f for f in os.listdir(self.backup_dir) if f.startswith(vdi_name) and f.endswith(".tmp")]
|
||||||
|
for backup_fail in backups_fail:
|
||||||
|
self.logger.debug('[%s] Delete backup "%s"', vdi_name, backup_fail)
|
||||||
|
os.unlink(os.path.join(self.backup_dir, backup_fail))
|
||||||
|
|
||||||
|
# add snapshot option
|
||||||
|
if not str2bool(halt_vm):
|
||||||
|
self.logger.debug("[%s] Check if previous tisbackups snapshots exist", vdi_name)
|
||||||
|
old_snapshots = session.xenapi.VM.get_by_name_label("tisbackup-%s" % (vdi_name))
|
||||||
|
self.logger.debug("[%s] Old snaps count %s", vdi_name, len(old_snapshots))
|
||||||
|
|
||||||
|
if len(old_snapshots) == 1 and str2bool(reuse_snapshot):
|
||||||
|
snapshot = old_snapshots[0]
|
||||||
|
self.logger.debug('[%s] Reusing snap "%s"', vdi_name, session.xenapi.VM.get_name_description(snapshot))
|
||||||
|
vm = snapshot # vm = session.xenapi.VM.get_by_name_label("tisbackup-%s"%(vdi_name))[0]
|
||||||
|
else:
|
||||||
|
self.logger.debug("[%s] Deleting %s old snaps", vdi_name, len(old_snapshots))
|
||||||
|
for old_snapshot in old_snapshots:
|
||||||
|
self.logger.debug("[%s] Destroy snapshot %s", vdi_name, session.xenapi.VM.get_name_description(old_snapshot))
|
||||||
|
try:
|
||||||
|
for vbd in session.xenapi.VM.get_VBDs(old_snapshot):
|
||||||
|
if session.xenapi.VBD.get_type(vbd) == "CD" and not session.xenapi.VBD.get_record(vbd)["empty"]:
|
||||||
|
session.xenapi.VBD.eject(vbd)
|
||||||
|
else:
|
||||||
|
vdi = session.xenapi.VBD.get_VDI(vbd)
|
||||||
|
if "NULL" not in vdi:
|
||||||
|
session.xenapi.VDI.destroy(vdi)
|
||||||
|
session.xenapi.VM.destroy(old_snapshot)
|
||||||
|
except XenAPI.Failure as error:
|
||||||
|
return "error when destroy snapshot %s" % (error)
|
||||||
|
|
||||||
|
now = datetime.datetime.now()
|
||||||
|
self.logger.debug("[%s] Snapshot in progress", vdi_name)
|
||||||
|
try:
|
||||||
|
snapshot = session.xenapi.VM.snapshot(vm, "tisbackup-%s" % (vdi_name))
|
||||||
|
self.logger.debug("[%s] got snapshot %s", vdi_name, snapshot)
|
||||||
|
except XenAPI.Failure as error:
|
||||||
|
return "error when snapshot %s" % (error)
|
||||||
|
# get snapshot opaqueRef
|
||||||
|
vm = session.xenapi.VM.get_by_name_label("tisbackup-%s" % (vdi_name))[0]
|
||||||
|
session.xenapi.VM.set_name_description(snapshot, "snapshot created by tisbackup on: %s" % (now.strftime("%Y-%m-%d %H:%M")))
|
||||||
|
else:
|
||||||
|
self.logger.debug("[%s] Status of VM: %s", self.backup_name, status_vm)
|
||||||
|
if status_vm == "Running":
|
||||||
|
self.logger.debug("[%s] Shudown in progress", self.backup_name)
|
||||||
|
if dry_run:
|
||||||
|
print("session.xenapi.VM.clean_shutdown(vm)")
|
||||||
|
else:
|
||||||
|
session.xenapi.VM.clean_shutdown(vm)
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
filename_temp = filename + ".tmp"
|
||||||
|
self.logger.debug("[%s] Copy in progress", self.backup_name)
|
||||||
|
if not str2bool(self.use_compression):
|
||||||
|
socket.setdefaulttimeout(120)
|
||||||
|
|
||||||
|
scheme = "http://"
|
||||||
|
if str2bool(enable_https):
|
||||||
|
scheme = "https://"
|
||||||
|
|
||||||
|
# url = scheme+user_xen+":"+password_xen+"@"+self.xcphost+"/export?use_compression="+self.use_compression+"&uuid="+session.xenapi.VM.get_uuid(vm)
|
||||||
|
top_level_url = (
|
||||||
|
scheme + self.xcphost + "/export?use_compression=" + self.use_compression + "&uuid=" + session.xenapi.VM.get_uuid(vm)
|
||||||
|
)
|
||||||
|
r = requests.get(top_level_url, auth=(user_xen, password_xen))
|
||||||
|
open(filename_temp, "wb").write(r.content)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error("[%s] error when fetching snap: %s", "tisbackup-%s" % (vdi_name), e)
|
||||||
|
if os.path.exists(filename_temp):
|
||||||
|
os.unlink(filename_temp)
|
||||||
|
raise
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if not str2bool(halt_vm):
|
||||||
|
self.logger.debug("[%s] Destroy snapshot", "tisbackup-%s" % (vdi_name))
|
||||||
|
try:
|
||||||
|
for vbd in session.xenapi.VM.get_VBDs(snapshot):
|
||||||
|
if session.xenapi.VBD.get_type(vbd) == "CD" and not session.xenapi.VBD.get_record(vbd)["empty"]:
|
||||||
|
session.xenapi.VBD.eject(vbd)
|
||||||
|
else:
|
||||||
|
vdi = session.xenapi.VBD.get_VDI(vbd)
|
||||||
|
if "NULL" not in vdi:
|
||||||
|
session.xenapi.VDI.destroy(vdi)
|
||||||
|
session.xenapi.VM.destroy(snapshot)
|
||||||
|
except XenAPI.Failure as error:
|
||||||
|
return "error when destroy snapshot %s" % (error)
|
||||||
|
|
||||||
|
elif status_vm == "Running":
|
||||||
|
self.logger.debug("[%s] Starting in progress", self.backup_name)
|
||||||
|
if dry_run:
|
||||||
|
print("session.xenapi.Async.VM.start(vm,False,True)")
|
||||||
|
else:
|
||||||
|
session.xenapi.Async.VM.start(vm, False, True)
|
||||||
|
|
||||||
|
session.logout()
|
||||||
|
|
||||||
|
if os.path.exists(filename_temp):
|
||||||
|
# Verify tar file integrity using subprocess instead of os.system
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["tar", "tf", filename_temp],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
timeout=300
|
||||||
|
)
|
||||||
|
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
||||||
|
os.unlink(filename_temp)
|
||||||
|
return "Tar error"
|
||||||
|
|
||||||
|
if str2bool(self.verify_export):
|
||||||
|
self.verify_export_xva(filename_temp)
|
||||||
|
os.rename(filename_temp, filename)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def do_backup(self, stats):
|
||||||
|
try:
|
||||||
|
dest_filename = os.path.join(self.backup_dir, "%s-%s.%s" % (self.backup_name, self.backup_start_date, "xva"))
|
||||||
|
|
||||||
|
# options = []
|
||||||
|
# options_params = " ".join(options)
|
||||||
|
cmd = self.export_xva(
|
||||||
|
vdi_name=self.server_name,
|
||||||
|
filename=dest_filename,
|
||||||
|
halt_vm=self.halt_vm,
|
||||||
|
enable_https=self.enable_https,
|
||||||
|
dry_run=self.dry_run,
|
||||||
|
reuse_snapshot=self.reuse_snapshot,
|
||||||
|
)
|
||||||
|
if os.path.exists(dest_filename):
|
||||||
|
stats["written_bytes"] = os.stat(dest_filename)[ST_SIZE]
|
||||||
|
stats["total_files_count"] = 1
|
||||||
|
stats["written_files_count"] = 1
|
||||||
|
stats["total_bytes"] = stats["written_bytes"]
|
||||||
|
else:
|
||||||
|
stats["written_bytes"] = 0
|
||||||
|
|
||||||
|
stats["backup_location"] = dest_filename
|
||||||
|
if cmd == 0:
|
||||||
|
stats["log"] = "XVA backup from %s OK, %d bytes written" % (self.server_name, stats["written_bytes"])
|
||||||
|
stats["status"] = "OK"
|
||||||
|
else:
|
||||||
|
raise Exception(cmd)
|
||||||
|
|
||||||
|
except BaseException as e:
|
||||||
|
stats["status"] = "ERROR"
|
||||||
|
stats["log"] = str(e)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def register_existingbackups(self):
|
||||||
|
"""scan backup dir and insert stats in database"""
|
||||||
|
|
||||||
|
registered = [
|
||||||
|
b["backup_location"]
|
||||||
|
for b in self.dbstat.query("select distinct backup_location from stats where backup_name=?", (self.backup_name,))
|
||||||
|
]
|
||||||
|
|
||||||
|
filelist = os.listdir(self.backup_dir)
|
||||||
|
filelist.sort()
|
||||||
|
for item in filelist:
|
||||||
|
if item.endswith(".xva"):
|
||||||
|
dir_name = os.path.join(self.backup_dir, item)
|
||||||
|
if dir_name not in registered:
|
||||||
|
start = (
|
||||||
|
datetime.datetime.strptime(item, self.backup_name + "-%Y%m%d-%Hh%Mm%S.xva") + datetime.timedelta(0, 30 * 60)
|
||||||
|
).isoformat()
|
||||||
|
if fileisodate(dir_name) > start:
|
||||||
|
stop = fileisodate(dir_name)
|
||||||
|
else:
|
||||||
|
stop = start
|
||||||
|
self.logger.info("Registering %s started on %s", dir_name, start)
|
||||||
|
self.logger.debug(" Disk usage %s", 'du -sb "%s"' % dir_name)
|
||||||
|
if not self.dry_run:
|
||||||
|
size_bytes = int(os.popen('du -sb "%s"' % dir_name).read().split("\t")[0])
|
||||||
|
else:
|
||||||
|
size_bytes = 0
|
||||||
|
self.logger.debug(" Size in bytes : %i", size_bytes)
|
||||||
|
if not self.dry_run:
|
||||||
|
self.dbstat.add(
|
||||||
|
self.backup_name,
|
||||||
|
self.server_name,
|
||||||
|
"",
|
||||||
|
backup_start=start,
|
||||||
|
backup_end=stop,
|
||||||
|
status="OK",
|
||||||
|
total_bytes=size_bytes,
|
||||||
|
backup_location=dir_name,
|
||||||
|
TYPE="BACKUP",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.logger.info("Skipping %s, already registered", dir_name)
|
||||||
|
|
||||||
|
|
||||||
|
register_driver(backup_xva)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
logger = logging.getLogger("tisbackup")
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
formatter = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
|
||||||
|
handler = logging.StreamHandler()
|
||||||
|
handler.setFormatter(formatter)
|
||||||
|
logger.addHandler(handler)
|
||||||
|
|
||||||
|
cp = ConfigParser()
|
||||||
|
cp.read("/opt/tisbackup/configtest.ini")
|
||||||
|
b = backup_xva()
|
||||||
|
b.read_config(cp)
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# This file is part of TISBackup
|
||||||
|
#
|
||||||
|
# TISBackup is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# TISBackup is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with TISBackup. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import os.path
|
||||||
|
import re
|
||||||
|
import select
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from stat import *
|
||||||
|
|
||||||
|
from . import XenAPI
|
||||||
|
from libtisbackup import *
|
||||||
|
|
||||||
|
if hasattr(ssl, "_create_unverified_context"):
|
||||||
|
ssl._create_default_https_context = ssl._create_unverified_context
|
||||||
|
|
||||||
|
|
||||||
|
class copy_vm_xcp(backup_generic):
|
||||||
|
"""Backup a VM running on a XCP server on a second SR (requires xe tools and XenAPI)"""
|
||||||
|
|
||||||
|
type = "copy-vm-xcp"
|
||||||
|
|
||||||
|
required_params = backup_generic.required_params + ["server_name", "storage_name", "password_file", "vm_name", "network_name"]
|
||||||
|
optional_params = backup_generic.optional_params + ["start_vm", "max_copies", "delete_snapshot", "halt_vm"]
|
||||||
|
|
||||||
|
start_vm = "no"
|
||||||
|
max_copies = 1
|
||||||
|
halt_vm = "no"
|
||||||
|
delete_snapshot = "yes"
|
||||||
|
|
||||||
|
def read_config(self, iniconf):
|
||||||
|
assert isinstance(iniconf, ConfigParser)
|
||||||
|
backup_generic.read_config(self, iniconf)
|
||||||
|
if self.start_vm in "no" and iniconf.has_option("global", "start_vm"):
|
||||||
|
self.start_vm = iniconf.get("global", "start_vm")
|
||||||
|
if self.max_copies == 1 and iniconf.has_option("global", "max_copies"):
|
||||||
|
self.max_copies = iniconf.getint("global", "max_copies")
|
||||||
|
if self.delete_snapshot == "yes" and iniconf.has_option("global", "delete_snapshot"):
|
||||||
|
self.delete_snapshot = iniconf.get("global", "delete_snapshot")
|
||||||
|
|
||||||
|
def copy_vm_to_sr(self, vm_name, storage_name, dry_run, delete_snapshot="yes"):
|
||||||
|
user_xen, password_xen, null = open(self.password_file).read().split("\n")
|
||||||
|
session = XenAPI.Session("https://" + self.server_name)
|
||||||
|
try:
|
||||||
|
session.login_with_password(user_xen, password_xen)
|
||||||
|
except XenAPI.Failure as error:
|
||||||
|
msg, ip = error.details
|
||||||
|
|
||||||
|
if msg == "HOST_IS_SLAVE":
|
||||||
|
server_name = ip
|
||||||
|
session = XenAPI.Session("https://" + server_name)
|
||||||
|
session.login_with_password(user_xen, password_xen)
|
||||||
|
|
||||||
|
self.logger.debug("[%s] VM (%s) to backup in storage: %s", self.backup_name, vm_name, storage_name)
|
||||||
|
now = datetime.datetime.now()
|
||||||
|
|
||||||
|
# get storage opaqueRef
|
||||||
|
try:
|
||||||
|
storage = session.xenapi.SR.get_by_name_label(storage_name)[0]
|
||||||
|
except IndexError as error:
|
||||||
|
result = (1, "error get SR opaqueref %s" % (error))
|
||||||
|
return result
|
||||||
|
|
||||||
|
# get vm to copy opaqueRef
|
||||||
|
try:
|
||||||
|
vm = session.xenapi.VM.get_by_name_label(vm_name)[0]
|
||||||
|
except IndexError as error:
|
||||||
|
result = (1, "error get VM opaqueref %s" % (error))
|
||||||
|
return result
|
||||||
|
|
||||||
|
# get vm backup network opaqueRef
|
||||||
|
try:
|
||||||
|
networkRef = session.xenapi.network.get_by_name_label(self.network_name)[0]
|
||||||
|
except IndexError as error:
|
||||||
|
result = (1, "error get VM network opaqueref %s" % (error))
|
||||||
|
return result
|
||||||
|
|
||||||
|
if str2bool(self.halt_vm):
|
||||||
|
status_vm = session.xenapi.VM.get_power_state(vm)
|
||||||
|
self.logger.debug("[%s] Status of VM: %s", self.backup_name, status_vm)
|
||||||
|
if status_vm == "Running":
|
||||||
|
self.logger.debug("[%s] Shutdown in progress", self.backup_name)
|
||||||
|
if dry_run:
|
||||||
|
print("session.xenapi.VM.clean_shutdown(vm)")
|
||||||
|
else:
|
||||||
|
session.xenapi.VM.clean_shutdown(vm)
|
||||||
|
snapshot = vm
|
||||||
|
else:
|
||||||
|
# do the snapshot
|
||||||
|
self.logger.debug("[%s] Snapshot in progress", self.backup_name)
|
||||||
|
try:
|
||||||
|
snapshot = session.xenapi.VM.snapshot(vm, "tisbackup-%s" % (vm_name))
|
||||||
|
except XenAPI.Failure as error:
|
||||||
|
result = (1, "error when snapshot %s" % (error))
|
||||||
|
return result
|
||||||
|
|
||||||
|
# get snapshot opaqueRef
|
||||||
|
snapshot = session.xenapi.VM.get_by_name_label("tisbackup-%s" % (vm_name))[0]
|
||||||
|
session.xenapi.VM.set_name_description(snapshot, "snapshot created by tisbackup on : %s" % (now.strftime("%Y-%m-%d %H:%M")))
|
||||||
|
|
||||||
|
vm_backup_name = "zzz-%s-" % (vm_name)
|
||||||
|
|
||||||
|
# Check if old backup exit
|
||||||
|
list_backups = []
|
||||||
|
for vm_ref in session.xenapi.VM.get_all():
|
||||||
|
name_lablel = session.xenapi.VM.get_name_label(vm_ref)
|
||||||
|
if vm_backup_name in name_lablel:
|
||||||
|
list_backups.append(name_lablel)
|
||||||
|
|
||||||
|
list_backups.sort()
|
||||||
|
|
||||||
|
if len(list_backups) >= 1:
|
||||||
|
# Shutting last backup if started
|
||||||
|
last_backup_vm = session.xenapi.VM.get_by_name_label(list_backups[-1])[0]
|
||||||
|
if "Halted" not in session.xenapi.VM.get_power_state(last_backup_vm):
|
||||||
|
self.logger.debug("[%s] Shutting down last backup vm : %s", self.backup_name, list_backups[-1])
|
||||||
|
session.xenapi.VM.hard_shutdown(last_backup_vm)
|
||||||
|
|
||||||
|
# Delete oldest backup if exist
|
||||||
|
if len(list_backups) >= int(self.max_copies):
|
||||||
|
for i in range(len(list_backups) - int(self.max_copies) + 1):
|
||||||
|
oldest_backup_vm = session.xenapi.VM.get_by_name_label(list_backups[i])[0]
|
||||||
|
if "Halted" not in session.xenapi.VM.get_power_state(oldest_backup_vm):
|
||||||
|
self.logger.debug("[%s] Shutting down old vm : %s", self.backup_name, list_backups[i])
|
||||||
|
session.xenapi.VM.hard_shutdown(oldest_backup_vm)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.logger.debug("[%s] Deleting old vm : %s", self.backup_name, list_backups[i])
|
||||||
|
for vbd in session.xenapi.VM.get_VBDs(oldest_backup_vm):
|
||||||
|
if session.xenapi.VBD.get_type(vbd) == "CD" and not session.xenapi.VBD.get_record(vbd)["empty"]:
|
||||||
|
session.xenapi.VBD.eject(vbd)
|
||||||
|
else:
|
||||||
|
vdi = session.xenapi.VBD.get_VDI(vbd)
|
||||||
|
if "NULL" not in vdi:
|
||||||
|
session.xenapi.VDI.destroy(vdi)
|
||||||
|
|
||||||
|
session.xenapi.VM.destroy(oldest_backup_vm)
|
||||||
|
except XenAPI.Failure as error:
|
||||||
|
result = (1, "error when destroy old backup vm %s" % (error))
|
||||||
|
return result
|
||||||
|
|
||||||
|
self.logger.debug("[%s] Copy %s in progress on %s", self.backup_name, vm_name, storage_name)
|
||||||
|
try:
|
||||||
|
backup_vm = session.xenapi.VM.copy(snapshot, vm_backup_name + now.strftime("%Y-%m-%d %H:%M"), storage)
|
||||||
|
except XenAPI.Failure as error:
|
||||||
|
result = (1, "error when copy %s" % (error))
|
||||||
|
return result
|
||||||
|
|
||||||
|
# define VM as a template
|
||||||
|
session.xenapi.VM.set_is_a_template(backup_vm, False)
|
||||||
|
|
||||||
|
# change the network of the new VM
|
||||||
|
try:
|
||||||
|
vifDestroy = session.xenapi.VM.get_VIFs(backup_vm)
|
||||||
|
except IndexError as error:
|
||||||
|
result = (1, "error get VIF opaqueref %s" % (error))
|
||||||
|
return result
|
||||||
|
|
||||||
|
for i in vifDestroy:
|
||||||
|
vifRecord = session.xenapi.VIF.get_record(i)
|
||||||
|
session.xenapi.VIF.destroy(i)
|
||||||
|
data = {
|
||||||
|
"MAC": vifRecord["MAC"],
|
||||||
|
"MAC_autogenerated": False,
|
||||||
|
"MTU": vifRecord["MTU"],
|
||||||
|
"VM": backup_vm,
|
||||||
|
"current_operations": vifRecord["current_operations"],
|
||||||
|
"currently_attached": vifRecord["currently_attached"],
|
||||||
|
"device": vifRecord["device"],
|
||||||
|
"ipv4_allowed": vifRecord["ipv4_allowed"],
|
||||||
|
"ipv6_allowed": vifRecord["ipv6_allowed"],
|
||||||
|
"locking_mode": vifRecord["locking_mode"],
|
||||||
|
"network": networkRef,
|
||||||
|
"other_config": vifRecord["other_config"],
|
||||||
|
"qos_algorithm_params": vifRecord["qos_algorithm_params"],
|
||||||
|
"qos_algorithm_type": vifRecord["qos_algorithm_type"],
|
||||||
|
"qos_supported_algorithms": vifRecord["qos_supported_algorithms"],
|
||||||
|
"runtime_properties": vifRecord["runtime_properties"],
|
||||||
|
"status_code": vifRecord["status_code"],
|
||||||
|
"status_detail": vifRecord["status_detail"],
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
session.xenapi.VIF.create(data)
|
||||||
|
except Exception as error:
|
||||||
|
result = (1, error)
|
||||||
|
return result
|
||||||
|
|
||||||
|
if self.start_vm in ["true", "1", "t", "y", "yes", "oui"]:
|
||||||
|
session.xenapi.VM.start(backup_vm, False, True)
|
||||||
|
|
||||||
|
session.xenapi.VM.set_name_description(backup_vm, "snapshot created by tisbackup on : %s" % (now.strftime("%Y-%m-%d %H:%M")))
|
||||||
|
|
||||||
|
size_backup = 0
|
||||||
|
for vbd in session.xenapi.VM.get_VBDs(backup_vm):
|
||||||
|
if session.xenapi.VBD.get_type(vbd) == "CD" and not session.xenapi.VBD.get_record(vbd)["empty"]:
|
||||||
|
session.xenapi.VBD.eject(vbd)
|
||||||
|
else:
|
||||||
|
vdi = session.xenapi.VBD.get_VDI(vbd)
|
||||||
|
if "NULL" not in vdi:
|
||||||
|
size_backup = size_backup + int(session.xenapi.VDI.get_record(vdi)["physical_utilisation"])
|
||||||
|
|
||||||
|
result = (0, size_backup)
|
||||||
|
if self.delete_snapshot == "no":
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Disable automatic boot
|
||||||
|
if "auto_poweron" in session.xenapi.VM.get_other_config(backup_vm):
|
||||||
|
session.xenapi.VM.remove_from_other_config(backup_vm, "auto_poweron")
|
||||||
|
|
||||||
|
if not str2bool(self.halt_vm):
|
||||||
|
# delete the snapshot
|
||||||
|
try:
|
||||||
|
for vbd in session.xenapi.VM.get_VBDs(snapshot):
|
||||||
|
if session.xenapi.VBD.get_type(vbd) == "CD" and not session.xenapi.VBD.get_record(vbd)["empty"]:
|
||||||
|
session.xenapi.VBD.eject(vbd)
|
||||||
|
else:
|
||||||
|
vdi = session.xenapi.VBD.get_VDI(vbd)
|
||||||
|
if "NULL" not in vdi:
|
||||||
|
session.xenapi.VDI.destroy(vdi)
|
||||||
|
session.xenapi.VM.destroy(snapshot)
|
||||||
|
except XenAPI.Failure as error:
|
||||||
|
result = (1, "error when destroy snapshot %s" % (error))
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
if status_vm == "Running":
|
||||||
|
self.logger.debug("[%s] Starting in progress", self.backup_name)
|
||||||
|
if dry_run:
|
||||||
|
print("session.xenapi.VM.start(vm,False,True)")
|
||||||
|
else:
|
||||||
|
session.xenapi.VM.start(vm, False, True)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def do_backup(self, stats):
|
||||||
|
try:
|
||||||
|
# timestamp = int(time.time())
|
||||||
|
cmd = self.copy_vm_to_sr(self.vm_name, self.storage_name, self.dry_run, delete_snapshot=self.delete_snapshot)
|
||||||
|
|
||||||
|
if cmd[0] == 0:
|
||||||
|
# timeExec = int(time.time()) - timestamp
|
||||||
|
stats["log"] = "copy of %s to an other storage OK" % (self.backup_name)
|
||||||
|
stats["status"] = "OK"
|
||||||
|
stats["total_files_count"] = 1
|
||||||
|
stats["total_bytes"] = cmd[1]
|
||||||
|
|
||||||
|
stats["backup_location"] = self.storage_name
|
||||||
|
else:
|
||||||
|
stats["status"] = "ERROR"
|
||||||
|
stats["log"] = cmd[1]
|
||||||
|
|
||||||
|
except BaseException as e:
|
||||||
|
stats["status"] = "ERROR"
|
||||||
|
stats["log"] = str(e)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def register_existingbackups(self):
|
||||||
|
"""scan backup dir and insert stats in database"""
|
||||||
|
# This backup is on target server, no data available on this server
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
register_driver(copy_vm_xcp)
|
||||||
@@ -8,18 +8,32 @@ from .config import BasicConfig, ConfigNamespace
|
|||||||
from .compat import RawConfigParser, ConfigParser, SafeConfigParser
|
from .compat import RawConfigParser, ConfigParser, SafeConfigParser
|
||||||
from .utils import tidy
|
from .utils import tidy
|
||||||
|
|
||||||
from .configparser import DuplicateSectionError, \
|
from .configparser import (
|
||||||
NoSectionError, NoOptionError, \
|
DuplicateSectionError,
|
||||||
InterpolationMissingOptionError, \
|
NoSectionError,
|
||||||
InterpolationDepthError, \
|
NoOptionError,
|
||||||
InterpolationSyntaxError, \
|
InterpolationMissingOptionError,
|
||||||
DEFAULTSECT, MAX_INTERPOLATION_DEPTH
|
InterpolationDepthError,
|
||||||
|
InterpolationSyntaxError,
|
||||||
|
DEFAULTSECT,
|
||||||
|
MAX_INTERPOLATION_DEPTH,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'BasicConfig', 'ConfigNamespace',
|
"BasicConfig",
|
||||||
'INIConfig', 'tidy', 'change_comment_syntax',
|
"ConfigNamespace",
|
||||||
'RawConfigParser', 'ConfigParser', 'SafeConfigParser',
|
"INIConfig",
|
||||||
'DuplicateSectionError', 'NoSectionError', 'NoOptionError',
|
"tidy",
|
||||||
'InterpolationMissingOptionError', 'InterpolationDepthError',
|
"change_comment_syntax",
|
||||||
'InterpolationSyntaxError', 'DEFAULTSECT', 'MAX_INTERPOLATION_DEPTH',
|
"RawConfigParser",
|
||||||
|
"ConfigParser",
|
||||||
|
"SafeConfigParser",
|
||||||
|
"DuplicateSectionError",
|
||||||
|
"NoSectionError",
|
||||||
|
"NoOptionError",
|
||||||
|
"InterpolationMissingOptionError",
|
||||||
|
"InterpolationDepthError",
|
||||||
|
"InterpolationSyntaxError",
|
||||||
|
"DEFAULTSECT",
|
||||||
|
"MAX_INTERPOLATION_DEPTH",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -12,44 +12,49 @@ The underlying INIConfig object can be accessed as cfg.data
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from .configparser import DuplicateSectionError, \
|
from typing import Dict, List, TextIO, Optional, Type, Union, Tuple
|
||||||
NoSectionError, NoOptionError, \
|
|
||||||
InterpolationMissingOptionError, \
|
|
||||||
InterpolationDepthError, \
|
|
||||||
InterpolationSyntaxError, \
|
|
||||||
DEFAULTSECT, MAX_INTERPOLATION_DEPTH
|
|
||||||
|
|
||||||
# These are imported only for compatiability.
|
from .configparser import (
|
||||||
|
DuplicateSectionError,
|
||||||
|
NoSectionError,
|
||||||
|
NoOptionError,
|
||||||
|
InterpolationMissingOptionError,
|
||||||
|
InterpolationDepthError,
|
||||||
|
InterpolationSyntaxError,
|
||||||
|
DEFAULTSECT,
|
||||||
|
MAX_INTERPOLATION_DEPTH,
|
||||||
|
)
|
||||||
|
|
||||||
|
# These are imported only for compatibility.
|
||||||
# The code below does not reference them directly.
|
# The code below does not reference them directly.
|
||||||
from .configparser import Error, InterpolationError, \
|
from .configparser import Error, InterpolationError, MissingSectionHeaderError, ParsingError
|
||||||
MissingSectionHeaderError, ParsingError
|
|
||||||
|
|
||||||
import six
|
|
||||||
|
|
||||||
from . import ini
|
from . import ini
|
||||||
|
|
||||||
|
|
||||||
class RawConfigParser(object):
|
class RawConfigParser:
|
||||||
def __init__(self, defaults=None, dict_type=dict):
|
def __init__(self, defaults=None, dict_type=dict):
|
||||||
if dict_type != dict:
|
if dict_type is not dict:
|
||||||
raise ValueError('Custom dict types not supported')
|
raise ValueError("Custom dict types not supported")
|
||||||
|
|
||||||
self.data = ini.INIConfig(defaults=defaults, optionxformsource=self)
|
self.data = ini.INIConfig(defaults=defaults, optionxformsource=self)
|
||||||
|
|
||||||
def optionxform(self, optionstr):
|
def optionxform(self, optionstr: str) -> str:
|
||||||
return optionstr.lower()
|
return optionstr.lower()
|
||||||
|
|
||||||
def defaults(self):
|
def defaults(self) -> Dict[str, str]:
|
||||||
d = {}
|
d: Dict[str, str] = {}
|
||||||
secobj = self.data._defaults
|
secobj: ini.INISection = self.data._defaults
|
||||||
|
name: str
|
||||||
for name in secobj._options:
|
for name in secobj._options:
|
||||||
d[name] = secobj._compat_get(name)
|
d[name] = secobj._compat_get(name)
|
||||||
return d
|
return d
|
||||||
|
|
||||||
def sections(self):
|
def sections(self) -> List[str]:
|
||||||
"""Return a list of section names, excluding [DEFAULT]"""
|
"""Return a list of section names, excluding [DEFAULT]"""
|
||||||
return list(self.data)
|
return list(self.data)
|
||||||
|
|
||||||
def add_section(self, section):
|
def add_section(self, section: str) -> None:
|
||||||
"""Create a new section in the configuration.
|
"""Create a new section in the configuration.
|
||||||
|
|
||||||
Raise DuplicateSectionError if a section by the specified name
|
Raise DuplicateSectionError if a section by the specified name
|
||||||
@@ -59,28 +64,28 @@ class RawConfigParser(object):
|
|||||||
# The default section is the only one that gets the case-insensitive
|
# The default section is the only one that gets the case-insensitive
|
||||||
# treatment - so it is special-cased here.
|
# treatment - so it is special-cased here.
|
||||||
if section.lower() == "default":
|
if section.lower() == "default":
|
||||||
raise ValueError('Invalid section name: %s' % section)
|
raise ValueError("Invalid section name: %s" % section)
|
||||||
|
|
||||||
if self.has_section(section):
|
if self.has_section(section):
|
||||||
raise DuplicateSectionError(section)
|
raise DuplicateSectionError(section)
|
||||||
else:
|
else:
|
||||||
self.data._new_namespace(section)
|
self.data._new_namespace(section)
|
||||||
|
|
||||||
def has_section(self, section):
|
def has_section(self, section: str) -> bool:
|
||||||
"""Indicate whether the named section is present in the configuration.
|
"""Indicate whether the named section is present in the configuration.
|
||||||
|
|
||||||
The DEFAULT section is not acknowledged.
|
The DEFAULT section is not acknowledged.
|
||||||
"""
|
"""
|
||||||
return section in self.data
|
return section in self.data
|
||||||
|
|
||||||
def options(self, section):
|
def options(self, section: str) -> List[str]:
|
||||||
"""Return a list of option names for the given section name."""
|
"""Return a list of option names for the given section name."""
|
||||||
if section in self.data:
|
if section in self.data:
|
||||||
return list(self.data[section])
|
return list(self.data[section])
|
||||||
else:
|
else:
|
||||||
raise NoSectionError(section)
|
raise NoSectionError(section)
|
||||||
|
|
||||||
def read(self, filenames):
|
def read(self, filenames: Union[List[str], str]) -> List[str]:
|
||||||
"""Read and parse a filename or a list of filenames.
|
"""Read and parse a filename or a list of filenames.
|
||||||
|
|
||||||
Files that cannot be opened are silently ignored; this is
|
Files that cannot be opened are silently ignored; this is
|
||||||
@@ -89,9 +94,11 @@ class RawConfigParser(object):
|
|||||||
home directory, systemwide directory), and all existing
|
home directory, systemwide directory), and all existing
|
||||||
configuration files in the list will be read. A single
|
configuration files in the list will be read. A single
|
||||||
filename may also be given.
|
filename may also be given.
|
||||||
|
|
||||||
|
Returns the list of files that were read.
|
||||||
"""
|
"""
|
||||||
files_read = []
|
files_read = []
|
||||||
if isinstance(filenames, six.string_types):
|
if isinstance(filenames, str):
|
||||||
filenames = [filenames]
|
filenames = [filenames]
|
||||||
for filename in filenames:
|
for filename in filenames:
|
||||||
try:
|
try:
|
||||||
@@ -103,7 +110,7 @@ class RawConfigParser(object):
|
|||||||
fp.close()
|
fp.close()
|
||||||
return files_read
|
return files_read
|
||||||
|
|
||||||
def readfp(self, fp, filename=None):
|
def readfp(self, fp: TextIO, filename: Optional[str] = None) -> None:
|
||||||
"""Like read() but the argument must be a file-like object.
|
"""Like read() but the argument must be a file-like object.
|
||||||
|
|
||||||
The `fp' argument must have a `readline' method. Optional
|
The `fp' argument must have a `readline' method. Optional
|
||||||
@@ -113,60 +120,70 @@ class RawConfigParser(object):
|
|||||||
"""
|
"""
|
||||||
self.data._readfp(fp)
|
self.data._readfp(fp)
|
||||||
|
|
||||||
def get(self, section, option, vars=None):
|
def get(self, section: str, option: str, vars: dict = None) -> str:
|
||||||
if not self.has_section(section):
|
if not self.has_section(section):
|
||||||
raise NoSectionError(section)
|
raise NoSectionError(section)
|
||||||
|
|
||||||
sec = self.data[section]
|
sec: ini.INISection = self.data[section]
|
||||||
if option in sec:
|
if option in sec:
|
||||||
return sec._compat_get(option)
|
return sec._compat_get(option)
|
||||||
else:
|
else:
|
||||||
raise NoOptionError(option, section)
|
raise NoOptionError(option, section)
|
||||||
|
|
||||||
def items(self, section):
|
def items(self, section: str) -> List[Tuple[str, str]]:
|
||||||
if section in self.data:
|
if section in self.data:
|
||||||
ans = []
|
ans = []
|
||||||
|
opt: str
|
||||||
for opt in self.data[section]:
|
for opt in self.data[section]:
|
||||||
ans.append((opt, self.get(section, opt)))
|
ans.append((opt, self.get(section, opt)))
|
||||||
return ans
|
return ans
|
||||||
else:
|
else:
|
||||||
raise NoSectionError(section)
|
raise NoSectionError(section)
|
||||||
|
|
||||||
def getint(self, section, option):
|
def getint(self, section: str, option: str) -> int:
|
||||||
return int(self.get(section, option))
|
return int(self.get(section, option))
|
||||||
|
|
||||||
def getfloat(self, section, option):
|
def getfloat(self, section: str, option: str) -> float:
|
||||||
return float(self.get(section, option))
|
return float(self.get(section, option))
|
||||||
|
|
||||||
_boolean_states = {'1': True, 'yes': True, 'true': True, 'on': True,
|
_boolean_states = {
|
||||||
'0': False, 'no': False, 'false': False, 'off': False}
|
"1": True,
|
||||||
|
"yes": True,
|
||||||
|
"true": True,
|
||||||
|
"on": True,
|
||||||
|
"0": False,
|
||||||
|
"no": False,
|
||||||
|
"false": False,
|
||||||
|
"off": False,
|
||||||
|
}
|
||||||
|
|
||||||
def getboolean(self, section, option):
|
def getboolean(self, section: str, option: str) -> bool:
|
||||||
v = self.get(section, option)
|
v = self.get(section, option)
|
||||||
if v.lower() not in self._boolean_states:
|
if v.lower() not in self._boolean_states:
|
||||||
raise ValueError('Not a boolean: %s' % v)
|
raise ValueError("Not a boolean: %s" % v)
|
||||||
return self._boolean_states[v.lower()]
|
return self._boolean_states[v.lower()]
|
||||||
|
|
||||||
def has_option(self, section, option):
|
def has_option(self, section: str, option: str) -> bool:
|
||||||
"""Check for the existence of a given option in a given section."""
|
"""Check for the existence of a given option in a given section."""
|
||||||
if section in self.data:
|
if section in self.data:
|
||||||
sec = self.data[section]
|
sec = self.data[section]
|
||||||
else:
|
else:
|
||||||
raise NoSectionError(section)
|
raise NoSectionError(section)
|
||||||
return (option in sec)
|
return option in sec
|
||||||
|
|
||||||
def set(self, section, option, value):
|
def set(self, section: str, option: str, value: str) -> None:
|
||||||
"""Set an option."""
|
"""Set an option."""
|
||||||
if section in self.data:
|
if section in self.data:
|
||||||
self.data[section][option] = value
|
self.data[section][option] = value
|
||||||
else:
|
else:
|
||||||
raise NoSectionError(section)
|
raise NoSectionError(section)
|
||||||
|
|
||||||
def write(self, fp):
|
def write(self, fp: TextIO) -> None:
|
||||||
"""Write an .ini-format representation of the configuration state."""
|
"""Write an .ini-format representation of the configuration state."""
|
||||||
fp.write(str(self.data))
|
fp.write(str(self.data))
|
||||||
|
|
||||||
def remove_option(self, section, option):
|
# FIXME Return a boolean instead of integer
|
||||||
|
def remove_option(self, section: str, option: str) -> int:
|
||||||
"""Remove an option."""
|
"""Remove an option."""
|
||||||
if section in self.data:
|
if section in self.data:
|
||||||
sec = self.data[section]
|
sec = self.data[section]
|
||||||
@@ -178,7 +195,7 @@ class RawConfigParser(object):
|
|||||||
else:
|
else:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
def remove_section(self, section):
|
def remove_section(self, section: str) -> bool:
|
||||||
"""Remove a file section."""
|
"""Remove a file section."""
|
||||||
if not self.has_section(section):
|
if not self.has_section(section):
|
||||||
return False
|
return False
|
||||||
@@ -186,15 +203,15 @@ class RawConfigParser(object):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
class ConfigDict(object):
|
class ConfigDict:
|
||||||
"""Present a dict interface to a ini section."""
|
"""Present a dict interface to an ini section."""
|
||||||
|
|
||||||
def __init__(self, cfg, section, vars):
|
def __init__(self, cfg: RawConfigParser, section: str, vars: dict):
|
||||||
self.cfg = cfg
|
self.cfg: RawConfigParser = cfg
|
||||||
self.section = section
|
self.section: str = section
|
||||||
self.vars = vars
|
self.vars: dict = vars
|
||||||
|
|
||||||
def __getitem__(self, key):
|
def __getitem__(self, key: str) -> Union[str, List[Union[int, str]]]:
|
||||||
try:
|
try:
|
||||||
return RawConfigParser.get(self.cfg, self.section, key, self.vars)
|
return RawConfigParser.get(self.cfg, self.section, key, self.vars)
|
||||||
except (NoOptionError, NoSectionError):
|
except (NoOptionError, NoSectionError):
|
||||||
@@ -202,8 +219,13 @@ class ConfigDict(object):
|
|||||||
|
|
||||||
|
|
||||||
class ConfigParser(RawConfigParser):
|
class ConfigParser(RawConfigParser):
|
||||||
|
def get(
|
||||||
def get(self, section, option, raw=False, vars=None):
|
self,
|
||||||
|
section: str,
|
||||||
|
option: str,
|
||||||
|
raw: bool = False,
|
||||||
|
vars: Optional[dict] = None,
|
||||||
|
) -> object:
|
||||||
"""Get an option value for a given section.
|
"""Get an option value for a given section.
|
||||||
|
|
||||||
All % interpolations are expanded in the return values, based on the
|
All % interpolations are expanded in the return values, based on the
|
||||||
@@ -226,25 +248,24 @@ class ConfigParser(RawConfigParser):
|
|||||||
d = ConfigDict(self, section, vars)
|
d = ConfigDict(self, section, vars)
|
||||||
return self._interpolate(section, option, value, d)
|
return self._interpolate(section, option, value, d)
|
||||||
|
|
||||||
def _interpolate(self, section, option, rawval, vars):
|
def _interpolate(self, section: str, option: str, rawval: object, vars: "ConfigDict"):
|
||||||
# do the string interpolation
|
# do the string interpolation
|
||||||
value = rawval
|
value = rawval
|
||||||
depth = MAX_INTERPOLATION_DEPTH
|
depth = MAX_INTERPOLATION_DEPTH
|
||||||
while depth: # Loop through this until it's done
|
while depth: # Loop through this until it's done
|
||||||
depth -= 1
|
depth -= 1
|
||||||
if "%(" in value:
|
if "%(" in value:
|
||||||
try:
|
try:
|
||||||
value = value % vars
|
value = value % vars
|
||||||
except KeyError as e:
|
except KeyError as e:
|
||||||
raise InterpolationMissingOptionError(
|
raise InterpolationMissingOptionError(option, section, rawval, e.args[0])
|
||||||
option, section, rawval, e.args[0])
|
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
if value.find("%(") != -1:
|
if value.find("%(") != -1:
|
||||||
raise InterpolationDepthError(option, section, rawval)
|
raise InterpolationDepthError(option, section, rawval)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def items(self, section, raw=False, vars=None):
|
def items(self, section: str, raw: bool = False, vars: Optional[dict] = None):
|
||||||
"""Return a list of tuples with (name, value) for each option
|
"""Return a list of tuples with (name, value) for each option
|
||||||
in the section.
|
in the section.
|
||||||
|
|
||||||
@@ -272,40 +293,37 @@ class ConfigParser(RawConfigParser):
|
|||||||
|
|
||||||
d = ConfigDict(self, section, vars)
|
d = ConfigDict(self, section, vars)
|
||||||
if raw:
|
if raw:
|
||||||
return [(option, d[option])
|
return [(option, d[option]) for option in options]
|
||||||
for option in options]
|
|
||||||
else:
|
else:
|
||||||
return [(option, self._interpolate(section, option, d[option], d))
|
return [(option, self._interpolate(section, option, d[option], d)) for option in options]
|
||||||
for option in options]
|
|
||||||
|
|
||||||
|
|
||||||
class SafeConfigParser(ConfigParser):
|
class SafeConfigParser(ConfigParser):
|
||||||
_interpvar_re = re.compile(r"%\(([^)]+)\)s")
|
_interpvar_re = re.compile(r"%\(([^)]+)\)s")
|
||||||
_badpercent_re = re.compile(r"%[^%]|%$")
|
_badpercent_re = re.compile(r"%[^%]|%$")
|
||||||
|
|
||||||
def set(self, section, option, value):
|
def set(self, section: str, option: str, value: object) -> None:
|
||||||
if not isinstance(value, six.string_types):
|
if not isinstance(value, str):
|
||||||
raise TypeError("option values must be strings")
|
raise TypeError("option values must be strings")
|
||||||
# check for bad percent signs:
|
# check for bad percent signs:
|
||||||
# first, replace all "good" interpolations
|
# first, replace all "good" interpolations
|
||||||
tmp_value = self._interpvar_re.sub('', value)
|
tmp_value = self._interpvar_re.sub("", value)
|
||||||
# then, check if there's a lone percent sign left
|
# then, check if there's a lone percent sign left
|
||||||
m = self._badpercent_re.search(tmp_value)
|
m = self._badpercent_re.search(tmp_value)
|
||||||
if m:
|
if m:
|
||||||
raise ValueError("invalid interpolation syntax in %r at "
|
raise ValueError("invalid interpolation syntax in %r at " "position %d" % (value, m.start()))
|
||||||
"position %d" % (value, m.start()))
|
|
||||||
|
|
||||||
ConfigParser.set(self, section, option, value)
|
ConfigParser.set(self, section, option, value)
|
||||||
|
|
||||||
def _interpolate(self, section, option, rawval, vars):
|
def _interpolate(self, section: str, option: str, rawval: str, vars: ConfigDict):
|
||||||
# do the string interpolation
|
# do the string interpolation
|
||||||
L = []
|
L = []
|
||||||
self._interpolate_some(option, L, rawval, section, vars, 1)
|
self._interpolate_some(option, L, rawval, section, vars, 1)
|
||||||
return ''.join(L)
|
return "".join(L)
|
||||||
|
|
||||||
_interpvar_match = re.compile(r"%\(([^)]+)\)s").match
|
_interpvar_match = re.compile(r"%\(([^)]+)\)s").match
|
||||||
|
|
||||||
def _interpolate_some(self, option, accum, rest, section, map, depth):
|
def _interpolate_some(self, option: str, accum: List[str], rest: str, section: str, map: ConfigDict, depth: int) -> None:
|
||||||
if depth > MAX_INTERPOLATION_DEPTH:
|
if depth > MAX_INTERPOLATION_DEPTH:
|
||||||
raise InterpolationDepthError(option, section, rest)
|
raise InterpolationDepthError(option, section, rest)
|
||||||
while rest:
|
while rest:
|
||||||
@@ -326,18 +344,14 @@ class SafeConfigParser(ConfigParser):
|
|||||||
if m is None:
|
if m is None:
|
||||||
raise InterpolationSyntaxError(option, section, "bad interpolation variable reference %r" % rest)
|
raise InterpolationSyntaxError(option, section, "bad interpolation variable reference %r" % rest)
|
||||||
var = m.group(1)
|
var = m.group(1)
|
||||||
rest = rest[m.end():]
|
rest = rest[m.end() :]
|
||||||
try:
|
try:
|
||||||
v = map[var]
|
v = map[var]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise InterpolationMissingOptionError(
|
raise InterpolationMissingOptionError(option, section, rest, var)
|
||||||
option, section, rest, var)
|
|
||||||
if "%" in v:
|
if "%" in v:
|
||||||
self._interpolate_some(option, accum, v,
|
self._interpolate_some(option, accum, v, section, map, depth + 1)
|
||||||
section, map, depth + 1)
|
|
||||||
else:
|
else:
|
||||||
accum.append(v)
|
accum.append(v)
|
||||||
else:
|
else:
|
||||||
raise InterpolationSyntaxError(
|
raise InterpolationSyntaxError(option, section, "'%' must be followed by '%' or '(', found: " + repr(rest))
|
||||||
option, section,
|
|
||||||
"'%' must be followed by '%' or '(', found: " + repr(rest))
|
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
class ConfigNamespace(object):
|
from typing import Dict, Iterable, List, TextIO, Union, TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .ini import INIConfig, INISection
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigNamespace:
|
||||||
"""Abstract class representing the interface of Config objects.
|
"""Abstract class representing the interface of Config objects.
|
||||||
|
|
||||||
A ConfigNamespace is a collection of names mapped to values, where
|
A ConfigNamespace is a collection of names mapped to values, where
|
||||||
@@ -12,27 +18,27 @@ class ConfigNamespace(object):
|
|||||||
|
|
||||||
Subclasses must implement the methods for container-like access,
|
Subclasses must implement the methods for container-like access,
|
||||||
and this class will automatically provide dotted access.
|
and this class will automatically provide dotted access.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Methods that must be implemented by subclasses
|
# Methods that must be implemented by subclasses
|
||||||
|
|
||||||
def _getitem(self, key):
|
def _getitem(self, key: str) -> object:
|
||||||
return NotImplementedError(key)
|
return NotImplementedError(key)
|
||||||
|
|
||||||
def __setitem__(self, key, value):
|
def __setitem__(self, key: str, value: object):
|
||||||
raise NotImplementedError(key, value)
|
raise NotImplementedError(key, value)
|
||||||
|
|
||||||
def __delitem__(self, key):
|
def __delitem__(self, key: str) -> None:
|
||||||
raise NotImplementedError(key)
|
raise NotImplementedError(key)
|
||||||
|
|
||||||
def __iter__(self):
|
def __iter__(self) -> Iterable[str]:
|
||||||
|
# FIXME Raise instead return
|
||||||
return NotImplementedError()
|
return NotImplementedError()
|
||||||
|
|
||||||
def _new_namespace(self, name):
|
def _new_namespace(self, name: str) -> "ConfigNamespace":
|
||||||
raise NotImplementedError(name)
|
raise NotImplementedError(name)
|
||||||
|
|
||||||
def __contains__(self, key):
|
def __contains__(self, key: str) -> bool:
|
||||||
try:
|
try:
|
||||||
self._getitem(key)
|
self._getitem(key)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
@@ -44,35 +50,35 @@ class ConfigNamespace(object):
|
|||||||
#
|
#
|
||||||
# To distinguish between accesses of class members and namespace
|
# To distinguish between accesses of class members and namespace
|
||||||
# keys, we first call object.__getattribute__(). If that succeeds,
|
# keys, we first call object.__getattribute__(). If that succeeds,
|
||||||
# the name is assumed to be a class member. Otherwise it is
|
# the name is assumed to be a class member. Otherwise, it is
|
||||||
# treated as a namespace key.
|
# treated as a namespace key.
|
||||||
#
|
#
|
||||||
# Therefore, member variables should be defined in the class,
|
# Therefore, member variables should be defined in the class,
|
||||||
# not just in the __init__() function. See BasicNamespace for
|
# not just in the __init__() function. See BasicNamespace for
|
||||||
# an example.
|
# an example.
|
||||||
|
|
||||||
def __getitem__(self, key):
|
def __getitem__(self, key: str) -> Union[object, "Undefined"]:
|
||||||
try:
|
try:
|
||||||
return self._getitem(key)
|
return self._getitem(key)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return Undefined(key, self)
|
return Undefined(key, self)
|
||||||
|
|
||||||
def __getattr__(self, name):
|
def __getattr__(self, name: str) -> Union[object, "Undefined"]:
|
||||||
try:
|
try:
|
||||||
return self._getitem(name)
|
return self._getitem(name)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
if name.startswith('__') and name.endswith('__'):
|
if name.startswith("__") and name.endswith("__"):
|
||||||
raise AttributeError
|
raise AttributeError
|
||||||
return Undefined(name, self)
|
return Undefined(name, self)
|
||||||
|
|
||||||
def __setattr__(self, name, value):
|
def __setattr__(self, name: str, value: object) -> None:
|
||||||
try:
|
try:
|
||||||
object.__getattribute__(self, name)
|
object.__getattribute__(self, name)
|
||||||
object.__setattr__(self, name, value)
|
object.__setattr__(self, name, value)
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
self.__setitem__(name, value)
|
self.__setitem__(name, value)
|
||||||
|
|
||||||
def __delattr__(self, name):
|
def __delattr__(self, name: str) -> None:
|
||||||
try:
|
try:
|
||||||
object.__getattribute__(self, name)
|
object.__getattribute__(self, name)
|
||||||
object.__delattr__(self, name)
|
object.__delattr__(self, name)
|
||||||
@@ -82,12 +88,12 @@ class ConfigNamespace(object):
|
|||||||
# During unpickling, Python checks if the class has a __setstate__
|
# During unpickling, Python checks if the class has a __setstate__
|
||||||
# method. But, the data dicts have not been initialised yet, which
|
# method. But, the data dicts have not been initialised yet, which
|
||||||
# leads to _getitem and hence __getattr__ raising an exception. So
|
# leads to _getitem and hence __getattr__ raising an exception. So
|
||||||
# we explicitly impement default __setstate__ behavior.
|
# we explicitly implement default __setstate__ behavior.
|
||||||
def __setstate__(self, state):
|
def __setstate__(self, state: dict) -> None:
|
||||||
self.__dict__.update(state)
|
self.__dict__.update(state)
|
||||||
|
|
||||||
|
|
||||||
class Undefined(object):
|
class Undefined:
|
||||||
"""Helper class used to hold undefined names until assignment.
|
"""Helper class used to hold undefined names until assignment.
|
||||||
|
|
||||||
This class helps create any undefined subsections when an
|
This class helps create any undefined subsections when an
|
||||||
@@ -95,21 +101,24 @@ class Undefined(object):
|
|||||||
statement is "cfg.a.b.c = 42", but "cfg.a.b" does not exist yet.
|
statement is "cfg.a.b.c = 42", but "cfg.a.b" does not exist yet.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, name, namespace):
|
def __init__(self, name: str, namespace: ConfigNamespace):
|
||||||
object.__setattr__(self, 'name', name)
|
# FIXME These assignments into `object` feel very strange.
|
||||||
object.__setattr__(self, 'namespace', namespace)
|
# What's the reason for it?
|
||||||
|
object.__setattr__(self, "name", name)
|
||||||
|
object.__setattr__(self, "namespace", namespace)
|
||||||
|
|
||||||
def __setattr__(self, name, value):
|
def __setattr__(self, name: str, value: object) -> None:
|
||||||
obj = self.namespace._new_namespace(self.name)
|
obj = self.namespace._new_namespace(self.name)
|
||||||
obj[name] = value
|
obj[name] = value
|
||||||
|
|
||||||
def __setitem__(self, name, value):
|
def __setitem__(self, name, value) -> None:
|
||||||
obj = self.namespace._new_namespace(self.name)
|
obj = self.namespace._new_namespace(self.name)
|
||||||
obj[name] = value
|
obj[name] = value
|
||||||
|
|
||||||
|
|
||||||
# ---- Basic implementation of a ConfigNamespace
|
# ---- Basic implementation of a ConfigNamespace
|
||||||
|
|
||||||
|
|
||||||
class BasicConfig(ConfigNamespace):
|
class BasicConfig(ConfigNamespace):
|
||||||
"""Represents a hierarchical collection of named values.
|
"""Represents a hierarchical collection of named values.
|
||||||
|
|
||||||
@@ -161,7 +170,7 @@ class BasicConfig(ConfigNamespace):
|
|||||||
|
|
||||||
Finally, values can be read from a file as follows:
|
Finally, values can be read from a file as follows:
|
||||||
|
|
||||||
>>> from six import StringIO
|
>>> from io import StringIO
|
||||||
>>> sio = StringIO('''
|
>>> sio = StringIO('''
|
||||||
... # comment
|
... # comment
|
||||||
... ui.height = 100
|
... ui.height = 100
|
||||||
@@ -181,66 +190,73 @@ class BasicConfig(ConfigNamespace):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# this makes sure that __setattr__ knows this is not a namespace key
|
# this makes sure that __setattr__ knows this is not a namespace key
|
||||||
_data = None
|
_data: Dict[str, str] = None
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._data = {}
|
self._data = {}
|
||||||
|
|
||||||
def _getitem(self, key):
|
def _getitem(self, key: str) -> str:
|
||||||
return self._data[key]
|
return self._data[key]
|
||||||
|
|
||||||
def __setitem__(self, key, value):
|
def __setitem__(self, key: str, value: object) -> None:
|
||||||
|
# FIXME We can add any object as 'value', but when an integer is read
|
||||||
|
# from a file, it will be a string. Should we explicitly convert
|
||||||
|
# this 'value' to string, to ensure consistency?
|
||||||
|
# It will stay the original type until it is written to a file.
|
||||||
self._data[key] = value
|
self._data[key] = value
|
||||||
|
|
||||||
def __delitem__(self, key):
|
def __delitem__(self, key: str) -> None:
|
||||||
del self._data[key]
|
del self._data[key]
|
||||||
|
|
||||||
def __iter__(self):
|
def __iter__(self) -> Iterable[str]:
|
||||||
return iter(self._data)
|
return iter(self._data)
|
||||||
|
|
||||||
def __str__(self, prefix=''):
|
def __str__(self, prefix: str = "") -> str:
|
||||||
lines = []
|
lines: List[str] = []
|
||||||
keys = list(self._data.keys())
|
keys: List[str] = list(self._data.keys())
|
||||||
keys.sort()
|
keys.sort()
|
||||||
for name in keys:
|
for name in keys:
|
||||||
value = self._data[name]
|
value: object = self._data[name]
|
||||||
if isinstance(value, ConfigNamespace):
|
if isinstance(value, ConfigNamespace):
|
||||||
lines.append(value.__str__(prefix='%s%s.' % (prefix,name)))
|
lines.append(value.__str__(prefix="%s%s." % (prefix, name)))
|
||||||
else:
|
else:
|
||||||
if value is None:
|
if value is None:
|
||||||
lines.append('%s%s' % (prefix, name))
|
lines.append("%s%s" % (prefix, name))
|
||||||
else:
|
else:
|
||||||
lines.append('%s%s = %s' % (prefix, name, value))
|
lines.append("%s%s = %s" % (prefix, name, value))
|
||||||
return '\n'.join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
def _new_namespace(self, name):
|
def _new_namespace(self, name: str) -> "BasicConfig":
|
||||||
obj = BasicConfig()
|
obj = BasicConfig()
|
||||||
self._data[name] = obj
|
self._data[name] = obj
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
def _readfp(self, fp):
|
def _readfp(self, fp: TextIO) -> None:
|
||||||
while True:
|
while True:
|
||||||
line = fp.readline()
|
line: str = fp.readline()
|
||||||
if not line:
|
if not line:
|
||||||
break
|
break
|
||||||
|
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if not line: continue
|
if not line:
|
||||||
if line[0] == '#': continue
|
continue
|
||||||
data = line.split('=', 1)
|
if line[0] == "#":
|
||||||
|
continue
|
||||||
|
data: List[str] = line.split("=", 1)
|
||||||
if len(data) == 1:
|
if len(data) == 1:
|
||||||
name = line
|
name = line
|
||||||
value = None
|
value = None
|
||||||
else:
|
else:
|
||||||
name = data[0].strip()
|
name = data[0].strip()
|
||||||
value = data[1].strip()
|
value = data[1].strip()
|
||||||
name_components = name.split('.')
|
name_components = name.split(".")
|
||||||
ns = self
|
ns: ConfigNamespace = self
|
||||||
for n in name_components[:-1]:
|
for n in name_components[:-1]:
|
||||||
if n in ns:
|
if n in ns:
|
||||||
ns = ns[n]
|
maybe_ns: object = ns[n]
|
||||||
if not isinstance(ns, ConfigNamespace):
|
if not isinstance(maybe_ns, ConfigNamespace):
|
||||||
raise TypeError('value-namespace conflict', n)
|
raise TypeError("value-namespace conflict", n)
|
||||||
|
ns = maybe_ns
|
||||||
else:
|
else:
|
||||||
ns = ns._new_namespace(n)
|
ns = ns._new_namespace(n)
|
||||||
ns[name_components[-1]] = value
|
ns[name_components[-1]] = value
|
||||||
@@ -248,7 +264,8 @@ class BasicConfig(ConfigNamespace):
|
|||||||
|
|
||||||
# ---- Utility functions
|
# ---- Utility functions
|
||||||
|
|
||||||
def update_config(target, source):
|
|
||||||
|
def update_config(target: ConfigNamespace, source: ConfigNamespace):
|
||||||
"""Imports values from source into target.
|
"""Imports values from source into target.
|
||||||
|
|
||||||
Recursively walks the <source> ConfigNamespace and inserts values
|
Recursively walks the <source> ConfigNamespace and inserts values
|
||||||
@@ -276,15 +293,15 @@ def update_config(target, source):
|
|||||||
display_clock = True
|
display_clock = True
|
||||||
display_qlength = True
|
display_qlength = True
|
||||||
width = 150
|
width = 150
|
||||||
|
|
||||||
"""
|
"""
|
||||||
for name in sorted(source):
|
for name in sorted(source):
|
||||||
value = source[name]
|
value: object = source[name]
|
||||||
if isinstance(value, ConfigNamespace):
|
if isinstance(value, ConfigNamespace):
|
||||||
if name in target:
|
if name in target:
|
||||||
myns = target[name]
|
maybe_myns: object = target[name]
|
||||||
if not isinstance(myns, ConfigNamespace):
|
if not isinstance(maybe_myns, ConfigNamespace):
|
||||||
raise TypeError('value-namespace conflict')
|
raise TypeError("value-namespace conflict")
|
||||||
|
myns = maybe_myns
|
||||||
else:
|
else:
|
||||||
myns = target._new_namespace(name)
|
myns = target._new_namespace(name)
|
||||||
update_config(myns, value)
|
update_config(myns, value)
|
||||||
|
|||||||
@@ -1,7 +1,2 @@
|
|||||||
try:
|
from configparser import *
|
||||||
from ConfigParser import *
|
from configparser import Error, InterpolationMissingOptionError
|
||||||
# not all objects get imported with __all__
|
|
||||||
from ConfigParser import Error, InterpolationMissingOptionError
|
|
||||||
except ImportError:
|
|
||||||
from configparser import *
|
|
||||||
from configparser import Error, InterpolationMissingOptionError
|
|
||||||
|
|||||||
+236
-202
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
>>> from six import StringIO
|
>>> from io import StringIO
|
||||||
>>> sio = StringIO('''# configure foo-application
|
>>> sio = StringIO('''# configure foo-application
|
||||||
... [foo]
|
... [foo]
|
||||||
... bar1 = qualia
|
... bar1 = qualia
|
||||||
@@ -39,26 +39,31 @@ Example:
|
|||||||
|
|
||||||
# An ini parser that supports ordered sections/options
|
# An ini parser that supports ordered sections/options
|
||||||
# Also supports updates, while preserving structure
|
# Also supports updates, while preserving structure
|
||||||
# Backward-compatiable with ConfigParser
|
# Backward-compatible with ConfigParser
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from .configparser import DEFAULTSECT, ParsingError, MissingSectionHeaderError
|
|
||||||
|
|
||||||
import six
|
from typing import Any, Callable, Dict, TextIO, Iterator, List, Optional, Set, Union
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from .configparser import DEFAULTSECT, ParsingError, MissingSectionHeaderError
|
||||||
|
|
||||||
from . import config
|
from . import config
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from compat import RawConfigParser
|
||||||
|
|
||||||
class LineType(object):
|
|
||||||
line = None
|
|
||||||
|
|
||||||
def __init__(self, line=None):
|
class LineType:
|
||||||
|
line: Optional[str] = None
|
||||||
|
|
||||||
|
def __init__(self, line: Optional[str] = None) -> None:
|
||||||
if line is not None:
|
if line is not None:
|
||||||
self.line = line.strip('\n')
|
self.line = line.strip("\n")
|
||||||
|
|
||||||
# Return the original line for unmodified objects
|
# Return the original line for unmodified objects
|
||||||
# Otherwise construct using the current attribute values
|
# Otherwise construct using the current attribute values
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
if self.line is not None:
|
if self.line is not None:
|
||||||
return self.line
|
return self.line
|
||||||
else:
|
else:
|
||||||
@@ -66,78 +71,87 @@ class LineType(object):
|
|||||||
|
|
||||||
# If an attribute is modified after initialization
|
# If an attribute is modified after initialization
|
||||||
# set line to None since it is no longer accurate.
|
# set line to None since it is no longer accurate.
|
||||||
def __setattr__(self, name, value):
|
def __setattr__(self, name: str, value: object) -> None:
|
||||||
if hasattr(self,name):
|
if hasattr(self, name):
|
||||||
self.__dict__['line'] = None
|
self.__dict__["line"] = None
|
||||||
self.__dict__[name] = value
|
self.__dict__[name] = value
|
||||||
|
|
||||||
def to_string(self):
|
def to_string(self) -> str:
|
||||||
raise Exception('This method must be overridden in derived classes')
|
# FIXME Raise NotImplementedError instead
|
||||||
|
raise Exception("This method must be overridden in derived classes")
|
||||||
|
|
||||||
|
|
||||||
class SectionLine(LineType):
|
class SectionLine(LineType):
|
||||||
regex = re.compile(r'^\['
|
regex = re.compile(r"^\[" r"(?P<name>[^]]+)" r"\]\s*" r"((?P<csep>;|#)(?P<comment>.*))?$")
|
||||||
r'(?P<name>[^]]+)'
|
|
||||||
r'\]\s*'
|
|
||||||
r'((?P<csep>;|#)(?P<comment>.*))?$')
|
|
||||||
|
|
||||||
def __init__(self, name, comment=None, comment_separator=None,
|
def __init__(
|
||||||
comment_offset=-1, line=None):
|
self,
|
||||||
super(SectionLine, self).__init__(line)
|
name: str,
|
||||||
self.name = name
|
comment: Optional[str] = None,
|
||||||
self.comment = comment
|
comment_separator: Optional[str] = None,
|
||||||
self.comment_separator = comment_separator
|
comment_offset: int = -1,
|
||||||
self.comment_offset = comment_offset
|
line: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(line)
|
||||||
|
self.name: str = name
|
||||||
|
self.comment: Optional[str] = comment
|
||||||
|
self.comment_separator: Optional[str] = comment_separator
|
||||||
|
self.comment_offset: int = comment_offset
|
||||||
|
|
||||||
def to_string(self):
|
def to_string(self) -> str:
|
||||||
out = '[' + self.name + ']'
|
out: str = "[" + self.name + "]"
|
||||||
if self.comment is not None:
|
if self.comment is not None:
|
||||||
# try to preserve indentation of comments
|
# try to preserve indentation of comments
|
||||||
out = (out+' ').ljust(self.comment_offset)
|
out = (out + " ").ljust(self.comment_offset)
|
||||||
out = out + self.comment_separator + self.comment
|
out = out + self.comment_separator + self.comment
|
||||||
return out
|
return out
|
||||||
|
|
||||||
def parse(cls, line):
|
@classmethod
|
||||||
m = cls.regex.match(line.rstrip())
|
def parse(cls, line: str) -> Optional["SectionLine"]:
|
||||||
|
m: Optional[re.Match] = cls.regex.match(line.rstrip())
|
||||||
if m is None:
|
if m is None:
|
||||||
return None
|
return None
|
||||||
return cls(m.group('name'), m.group('comment'),
|
return cls(m.group("name"), m.group("comment"), m.group("csep"), m.start("csep"), line)
|
||||||
m.group('csep'), m.start('csep'),
|
|
||||||
line)
|
|
||||||
parse = classmethod(parse)
|
|
||||||
|
|
||||||
|
|
||||||
class OptionLine(LineType):
|
class OptionLine(LineType):
|
||||||
def __init__(self, name, value, separator=' = ', comment=None,
|
def __init__(
|
||||||
comment_separator=None, comment_offset=-1, line=None):
|
self,
|
||||||
super(OptionLine, self).__init__(line)
|
name: str,
|
||||||
self.name = name
|
value: object,
|
||||||
self.value = value
|
separator: str = " = ",
|
||||||
self.separator = separator
|
comment: Optional[str] = None,
|
||||||
self.comment = comment
|
comment_separator: Optional[str] = None,
|
||||||
self.comment_separator = comment_separator
|
comment_offset: int = -1,
|
||||||
self.comment_offset = comment_offset
|
line: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(line)
|
||||||
|
self.name: str = name
|
||||||
|
self.value: object = value
|
||||||
|
self.separator: str = separator
|
||||||
|
self.comment: Optional[str] = comment
|
||||||
|
self.comment_separator: Optional[str] = comment_separator
|
||||||
|
self.comment_offset: int = comment_offset
|
||||||
|
|
||||||
def to_string(self):
|
def to_string(self) -> str:
|
||||||
out = '%s%s%s' % (self.name, self.separator, self.value)
|
out: str = "%s%s%s" % (self.name, self.separator, self.value)
|
||||||
if self.comment is not None:
|
if self.comment is not None:
|
||||||
# try to preserve indentation of comments
|
# try to preserve indentation of comments
|
||||||
out = (out+' ').ljust(self.comment_offset)
|
out = (out + " ").ljust(self.comment_offset)
|
||||||
out = out + self.comment_separator + self.comment
|
out = out + self.comment_separator + self.comment
|
||||||
return out
|
return out
|
||||||
|
|
||||||
regex = re.compile(r'^(?P<name>[^:=\s[][^:=]*)'
|
regex = re.compile(r"^(?P<name>[^:=\s[][^:=]*)" r"(?P<sep>[:=]\s*)" r"(?P<value>.*)$")
|
||||||
r'(?P<sep>[:=]\s*)'
|
|
||||||
r'(?P<value>.*)$')
|
|
||||||
|
|
||||||
def parse(cls, line):
|
@classmethod
|
||||||
m = cls.regex.match(line.rstrip())
|
def parse(cls, line: str) -> Optional["OptionLine"]:
|
||||||
|
m: Optional[re.Match] = cls.regex.match(line.rstrip())
|
||||||
if m is None:
|
if m is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
name = m.group('name').rstrip()
|
name: str = m.group("name").rstrip()
|
||||||
value = m.group('value')
|
value: str = m.group("value")
|
||||||
sep = m.group('name')[len(name):] + m.group('sep')
|
sep: str = m.group("name")[len(name) :] + m.group("sep")
|
||||||
|
|
||||||
# comments are not detected in the regex because
|
# comments are not detected in the regex because
|
||||||
# ensuring total compatibility with ConfigParser
|
# ensuring total compatibility with ConfigParser
|
||||||
@@ -150,123 +164,120 @@ class OptionLine(LineType):
|
|||||||
# include ';' in the value needs to be addressed.
|
# include ';' in the value needs to be addressed.
|
||||||
# Also, '#' doesn't mark comments in options...
|
# Also, '#' doesn't mark comments in options...
|
||||||
|
|
||||||
coff = value.find(';')
|
coff: int = value.find(";")
|
||||||
if coff != -1 and value[coff-1].isspace():
|
if coff != -1 and value[coff - 1].isspace():
|
||||||
comment = value[coff+1:]
|
comment = value[coff + 1 :]
|
||||||
csep = value[coff]
|
csep = value[coff]
|
||||||
value = value[:coff].rstrip()
|
value = value[:coff].rstrip()
|
||||||
coff = m.start('value') + coff
|
coff = m.start("value") + coff
|
||||||
else:
|
else:
|
||||||
comment = None
|
comment = None
|
||||||
csep = None
|
csep = None
|
||||||
coff = -1
|
coff = -1
|
||||||
|
|
||||||
return cls(name, value, sep, comment, csep, coff, line)
|
return cls(name, value, sep, comment, csep, coff, line)
|
||||||
parse = classmethod(parse)
|
|
||||||
|
|
||||||
|
|
||||||
def change_comment_syntax(comment_chars='%;#', allow_rem=False):
|
def change_comment_syntax(comment_chars: str = "%;#", allow_rem: bool = False) -> None:
|
||||||
comment_chars = re.sub(r'([\]\-\^])', r'\\\1', comment_chars)
|
comment_chars: str = re.sub(r"([\]\-\^])", r"\\\1", comment_chars)
|
||||||
regex = r'^(?P<csep>[%s]' % comment_chars
|
regex: str = r"^(?P<csep>[%s]" % comment_chars
|
||||||
if allow_rem:
|
if allow_rem:
|
||||||
regex += '|[rR][eE][mM]'
|
regex += "|[rR][eE][mM]"
|
||||||
regex += r')(?P<comment>.*)$'
|
regex += r")(?P<comment>.*)$"
|
||||||
CommentLine.regex = re.compile(regex)
|
CommentLine.regex = re.compile(regex)
|
||||||
|
|
||||||
|
|
||||||
class CommentLine(LineType):
|
class CommentLine(LineType):
|
||||||
regex = re.compile(r'^(?P<csep>[;#])'
|
regex: re.Pattern = re.compile(r"^(?P<csep>[;#]|[rR][eE][mM])" r"(?P<comment>.*)$")
|
||||||
r'(?P<comment>.*)$')
|
|
||||||
|
|
||||||
def __init__(self, comment='', separator='#', line=None):
|
def __init__(self, comment: str = "", separator: str = "#", line: Optional[str] = None) -> None:
|
||||||
super(CommentLine, self).__init__(line)
|
super().__init__(line)
|
||||||
self.comment = comment
|
self.comment: str = comment
|
||||||
self.separator = separator
|
self.separator: str = separator
|
||||||
|
|
||||||
def to_string(self):
|
def to_string(self) -> str:
|
||||||
return self.separator + self.comment
|
return self.separator + self.comment
|
||||||
|
|
||||||
def parse(cls, line):
|
@classmethod
|
||||||
m = cls.regex.match(line.rstrip())
|
def parse(cls, line: str) -> Optional["CommentLine"]:
|
||||||
|
m: Optional[re.Match] = cls.regex.match(line.rstrip())
|
||||||
if m is None:
|
if m is None:
|
||||||
return None
|
return None
|
||||||
return cls(m.group('comment'), m.group('csep'), line)
|
return cls(m.group("comment"), m.group("csep"), line)
|
||||||
|
|
||||||
parse = classmethod(parse)
|
|
||||||
|
|
||||||
|
|
||||||
class EmptyLine(LineType):
|
class EmptyLine(LineType):
|
||||||
# could make this a singleton
|
# could make this a singleton
|
||||||
def to_string(self):
|
def to_string(self) -> str:
|
||||||
return ''
|
return ""
|
||||||
|
|
||||||
value = property(lambda self: '')
|
value = property(lambda self: "")
|
||||||
|
|
||||||
def parse(cls, line):
|
@classmethod
|
||||||
|
def parse(cls, line: str) -> Optional["EmptyLine"]:
|
||||||
if line.strip():
|
if line.strip():
|
||||||
return None
|
return None
|
||||||
return cls(line)
|
return cls(line)
|
||||||
|
|
||||||
parse = classmethod(parse)
|
|
||||||
|
|
||||||
|
|
||||||
class ContinuationLine(LineType):
|
class ContinuationLine(LineType):
|
||||||
regex = re.compile(r'^\s+(?P<value>.*)$')
|
regex: re.Pattern = re.compile(r"^\s+(?P<value>.*)$")
|
||||||
|
|
||||||
def __init__(self, value, value_offset=None, line=None):
|
def __init__(self, value: str, value_offset: Optional[int] = None, line: Optional[str] = None) -> None:
|
||||||
super(ContinuationLine, self).__init__(line)
|
super().__init__(line)
|
||||||
self.value = value
|
self.value = value
|
||||||
if value_offset is None:
|
if value_offset is None:
|
||||||
value_offset = 8
|
value_offset = 8
|
||||||
self.value_offset = value_offset
|
self.value_offset: int = value_offset
|
||||||
|
|
||||||
def to_string(self):
|
def to_string(self) -> str:
|
||||||
return ' '*self.value_offset + self.value
|
return " " * self.value_offset + self.value
|
||||||
|
|
||||||
def parse(cls, line):
|
@classmethod
|
||||||
m = cls.regex.match(line.rstrip())
|
def parse(cls, line: str) -> Optional["ContinuationLine"]:
|
||||||
|
m: Optional[re.Match] = cls.regex.match(line.rstrip())
|
||||||
if m is None:
|
if m is None:
|
||||||
return None
|
return None
|
||||||
return cls(m.group('value'), m.start('value'), line)
|
return cls(m.group("value"), m.start("value"), line)
|
||||||
|
|
||||||
parse = classmethod(parse)
|
|
||||||
|
|
||||||
|
|
||||||
class LineContainer(object):
|
class LineContainer:
|
||||||
def __init__(self, d=None):
|
def __init__(self, d: Optional[Union[List[LineType], LineType]] = None) -> None:
|
||||||
self.contents = []
|
self.contents = []
|
||||||
self.orgvalue = None
|
self.orgvalue: str = None
|
||||||
if d:
|
if d:
|
||||||
if isinstance(d, list): self.extend(d)
|
if isinstance(d, list):
|
||||||
else: self.add(d)
|
self.extend(d)
|
||||||
|
else:
|
||||||
|
self.add(d)
|
||||||
|
|
||||||
def add(self, x):
|
def add(self, x: LineType) -> None:
|
||||||
self.contents.append(x)
|
self.contents.append(x)
|
||||||
|
|
||||||
def extend(self, x):
|
def extend(self, x: List[LineType]) -> None:
|
||||||
for i in x: self.add(i)
|
for i in x:
|
||||||
|
self.add(i)
|
||||||
|
|
||||||
def get_name(self):
|
def get_name(self) -> str:
|
||||||
return self.contents[0].name
|
return self.contents[0].name
|
||||||
|
|
||||||
def set_name(self, data):
|
def set_name(self, data: str) -> None:
|
||||||
self.contents[0].name = data
|
self.contents[0].name = data
|
||||||
|
|
||||||
def get_value(self):
|
def get_value(self) -> str:
|
||||||
if self.orgvalue is not None:
|
if self.orgvalue is not None:
|
||||||
return self.orgvalue
|
return self.orgvalue
|
||||||
elif len(self.contents) == 1:
|
elif len(self.contents) == 1:
|
||||||
return self.contents[0].value
|
return self.contents[0].value
|
||||||
else:
|
else:
|
||||||
return '\n'.join([('%s' % x.value) for x in self.contents
|
return "\n".join([("%s" % x.value) for x in self.contents if not isinstance(x, CommentLine)])
|
||||||
if not isinstance(x, CommentLine)])
|
|
||||||
|
|
||||||
def set_value(self, data):
|
def set_value(self, data: object) -> None:
|
||||||
self.orgvalue = data
|
self.orgvalue = data
|
||||||
lines = ('%s' % data).split('\n')
|
lines: List[str] = ("%s" % data).split("\n")
|
||||||
|
|
||||||
# If there is an existing ContinuationLine, use its offset
|
# If there is an existing ContinuationLine, use its offset
|
||||||
value_offset = None
|
value_offset: Optional[int] = None
|
||||||
for v in self.contents:
|
for v in self.contents:
|
||||||
if isinstance(v, ContinuationLine):
|
if isinstance(v, ContinuationLine):
|
||||||
value_offset = v.value_offset
|
value_offset = v.value_offset
|
||||||
@@ -282,40 +293,45 @@ class LineContainer(object):
|
|||||||
else:
|
else:
|
||||||
self.add(EmptyLine())
|
self.add(EmptyLine())
|
||||||
|
|
||||||
|
def get_line_number(self) -> Optional[int]:
|
||||||
|
return self.contents[0].line_number if self.contents else None
|
||||||
|
|
||||||
name = property(get_name, set_name)
|
name = property(get_name, set_name)
|
||||||
|
|
||||||
value = property(get_value, set_value)
|
value = property(get_value, set_value)
|
||||||
|
|
||||||
def __str__(self):
|
line_number = property(get_line_number)
|
||||||
s = [x.__str__() for x in self.contents]
|
|
||||||
return '\n'.join(s)
|
|
||||||
|
|
||||||
def finditer(self, key):
|
def __str__(self) -> str:
|
||||||
|
s: List[str] = [x.__str__() for x in self.contents]
|
||||||
|
return "\n".join(s)
|
||||||
|
|
||||||
|
def finditer(self, key: str) -> Iterator[Union[SectionLine, OptionLine]]:
|
||||||
for x in self.contents[::-1]:
|
for x in self.contents[::-1]:
|
||||||
if hasattr(x, 'name') and x.name==key:
|
if hasattr(x, "name") and x.name == key:
|
||||||
yield x
|
yield x
|
||||||
|
|
||||||
def find(self, key):
|
def find(self, key: str) -> Union[SectionLine, OptionLine]:
|
||||||
for x in self.finditer(key):
|
for x in self.finditer(key):
|
||||||
return x
|
return x
|
||||||
raise KeyError(key)
|
raise KeyError(key)
|
||||||
|
|
||||||
|
|
||||||
def _make_xform_property(myattrname, srcattrname=None):
|
def _make_xform_property(myattrname: str, srcattrname: Optional[str] = None) -> property:
|
||||||
private_attrname = myattrname + 'value'
|
private_attrname: str = myattrname + "value"
|
||||||
private_srcname = myattrname + 'source'
|
private_srcname: str = myattrname + "source"
|
||||||
if srcattrname is None:
|
if srcattrname is None:
|
||||||
srcattrname = myattrname
|
srcattrname = myattrname
|
||||||
|
|
||||||
def getfn(self):
|
def getfn(self) -> Callable:
|
||||||
srcobj = getattr(self, private_srcname)
|
srcobj: Optional[object] = getattr(self, private_srcname)
|
||||||
if srcobj is not None:
|
if srcobj is not None:
|
||||||
return getattr(srcobj, srcattrname)
|
return getattr(srcobj, srcattrname)
|
||||||
else:
|
else:
|
||||||
return getattr(self, private_attrname)
|
return getattr(self, private_attrname)
|
||||||
|
|
||||||
def setfn(self, value):
|
def setfn(self, value: Callable) -> None:
|
||||||
srcobj = getattr(self, private_srcname)
|
srcobj: Optional[object] = getattr(self, private_srcname)
|
||||||
if srcobj is not None:
|
if srcobj is not None:
|
||||||
setattr(srcobj, srcattrname, value)
|
setattr(srcobj, srcattrname, value)
|
||||||
else:
|
else:
|
||||||
@@ -325,31 +341,38 @@ def _make_xform_property(myattrname, srcattrname=None):
|
|||||||
|
|
||||||
|
|
||||||
class INISection(config.ConfigNamespace):
|
class INISection(config.ConfigNamespace):
|
||||||
_lines = None
|
_lines: List[LineContainer] = None
|
||||||
_options = None
|
_options: Dict[str, object] = None
|
||||||
_defaults = None
|
_defaults: Optional["INISection"] = None
|
||||||
_optionxformvalue = None
|
_optionxformvalue: "INIConfig" = None
|
||||||
_optionxformsource = None
|
_optionxformsource: "INIConfig" = None
|
||||||
_compat_skip_empty_lines = set()
|
_compat_skip_empty_lines: Set[str] = set()
|
||||||
|
|
||||||
def __init__(self, lineobj, defaults=None, optionxformvalue=None, optionxformsource=None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
lineobj: LineContainer,
|
||||||
|
defaults: Optional["INISection"] = None,
|
||||||
|
optionxformvalue: Optional["INIConfig"] = None,
|
||||||
|
optionxformsource: Optional["INIConfig"] = None,
|
||||||
|
) -> None:
|
||||||
self._lines = [lineobj]
|
self._lines = [lineobj]
|
||||||
self._defaults = defaults
|
self._defaults = defaults
|
||||||
self._optionxformvalue = optionxformvalue
|
self._optionxformvalue = optionxformvalue
|
||||||
self._optionxformsource = optionxformsource
|
self._optionxformsource = optionxformsource
|
||||||
self._options = {}
|
self._options = {}
|
||||||
|
|
||||||
_optionxform = _make_xform_property('_optionxform')
|
_optionxform = _make_xform_property("_optionxform")
|
||||||
|
|
||||||
def _compat_get(self, key):
|
def _compat_get(self, key: str) -> str:
|
||||||
# identical to __getitem__ except that _compat_XXX
|
# identical to __getitem__ except that _compat_XXX
|
||||||
# is checked for backward-compatible handling
|
# is checked for backward-compatible handling
|
||||||
if key == '__name__':
|
if key == "__name__":
|
||||||
return self._lines[-1].name
|
return self._lines[-1].name
|
||||||
if self._optionxform: key = self._optionxform(key)
|
if self._optionxform:
|
||||||
|
key = self._optionxform(key)
|
||||||
try:
|
try:
|
||||||
value = self._options[key].value
|
value: str = self._options[key].value
|
||||||
del_empty = key in self._compat_skip_empty_lines
|
del_empty: bool = key in self._compat_skip_empty_lines
|
||||||
except KeyError:
|
except KeyError:
|
||||||
if self._defaults and key in self._defaults._options:
|
if self._defaults and key in self._defaults._options:
|
||||||
value = self._defaults._options[key].value
|
value = self._defaults._options[key].value
|
||||||
@@ -357,13 +380,14 @@ class INISection(config.ConfigNamespace):
|
|||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
if del_empty:
|
if del_empty:
|
||||||
value = re.sub('\n+', '\n', value)
|
value = re.sub("\n+", "\n", value)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def _getitem(self, key):
|
def _getitem(self, key: str) -> object:
|
||||||
if key == '__name__':
|
if key == "__name__":
|
||||||
return self._lines[-1].name
|
return self._lines[-1].name
|
||||||
if self._optionxform: key = self._optionxform(key)
|
if self._optionxform:
|
||||||
|
key = self._optionxform(key)
|
||||||
try:
|
try:
|
||||||
return self._options[key].value
|
return self._options[key].value
|
||||||
except KeyError:
|
except KeyError:
|
||||||
@@ -372,22 +396,25 @@ class INISection(config.ConfigNamespace):
|
|||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def __setitem__(self, key, value):
|
def __setitem__(self, key: str, value: object) -> None:
|
||||||
if self._optionxform: xkey = self._optionxform(key)
|
if self._optionxform:
|
||||||
else: xkey = key
|
xkey = self._optionxform(key)
|
||||||
|
else:
|
||||||
|
xkey = key
|
||||||
if xkey in self._compat_skip_empty_lines:
|
if xkey in self._compat_skip_empty_lines:
|
||||||
self._compat_skip_empty_lines.remove(xkey)
|
self._compat_skip_empty_lines.remove(xkey)
|
||||||
if xkey not in self._options:
|
if xkey not in self._options:
|
||||||
# create a dummy object - value may have multiple lines
|
# create a dummy object - value may have multiple lines
|
||||||
obj = LineContainer(OptionLine(key, ''))
|
obj = LineContainer(OptionLine(key, ""))
|
||||||
self._lines[-1].add(obj)
|
self._lines[-1].add(obj)
|
||||||
self._options[xkey] = obj
|
self._options[xkey] = obj
|
||||||
# the set_value() function in LineContainer
|
# the set_value() function in LineContainer
|
||||||
# automatically handles multi-line values
|
# automatically handles multi-line values
|
||||||
self._options[xkey].value = value
|
self._options[xkey].value = value
|
||||||
|
|
||||||
def __delitem__(self, key):
|
def __delitem__(self, key: str) -> None:
|
||||||
if self._optionxform: key = self._optionxform(key)
|
if self._optionxform:
|
||||||
|
key = self._optionxform(key)
|
||||||
if key in self._compat_skip_empty_lines:
|
if key in self._compat_skip_empty_lines:
|
||||||
self._compat_skip_empty_lines.remove(key)
|
self._compat_skip_empty_lines.remove(key)
|
||||||
for l in self._lines:
|
for l in self._lines:
|
||||||
@@ -395,14 +422,16 @@ class INISection(config.ConfigNamespace):
|
|||||||
for o in l.contents:
|
for o in l.contents:
|
||||||
if isinstance(o, LineContainer):
|
if isinstance(o, LineContainer):
|
||||||
n = o.name
|
n = o.name
|
||||||
if self._optionxform: n = self._optionxform(n)
|
if self._optionxform:
|
||||||
if key != n: remaining.append(o)
|
n = self._optionxform(n)
|
||||||
|
if key != n:
|
||||||
|
remaining.append(o)
|
||||||
else:
|
else:
|
||||||
remaining.append(o)
|
remaining.append(o)
|
||||||
l.contents = remaining
|
l.contents = remaining
|
||||||
del self._options[key]
|
del self._options[key]
|
||||||
|
|
||||||
def __iter__(self):
|
def __iter__(self) -> Iterator[str]:
|
||||||
d = set()
|
d = set()
|
||||||
for l in self._lines:
|
for l in self._lines:
|
||||||
for x in l.contents:
|
for x in l.contents:
|
||||||
@@ -421,26 +450,25 @@ class INISection(config.ConfigNamespace):
|
|||||||
d.add(x)
|
d.add(x)
|
||||||
|
|
||||||
def _new_namespace(self, name):
|
def _new_namespace(self, name):
|
||||||
raise Exception('No sub-sections allowed', name)
|
raise Exception("No sub-sections allowed", name)
|
||||||
|
|
||||||
|
|
||||||
def make_comment(line):
|
def make_comment(line: str) -> CommentLine:
|
||||||
return CommentLine(line.rstrip('\n'))
|
return CommentLine(line.rstrip("\n"))
|
||||||
|
|
||||||
|
|
||||||
def readline_iterator(f):
|
def readline_iterator(f: TextIO) -> Iterator[str]:
|
||||||
"""iterate over a file by only using the file object's readline method"""
|
"""Iterate over a file by only using the file object's readline method."""
|
||||||
|
have_newline: bool = False
|
||||||
have_newline = False
|
|
||||||
while True:
|
while True:
|
||||||
line = f.readline()
|
line: Optional[str] = f.readline()
|
||||||
|
|
||||||
if not line:
|
if not line:
|
||||||
if have_newline:
|
if have_newline:
|
||||||
yield ""
|
yield ""
|
||||||
return
|
return
|
||||||
|
|
||||||
if line.endswith('\n'):
|
if line.endswith("\n"):
|
||||||
have_newline = True
|
have_newline = True
|
||||||
else:
|
else:
|
||||||
have_newline = False
|
have_newline = False
|
||||||
@@ -448,57 +476,67 @@ def readline_iterator(f):
|
|||||||
yield line
|
yield line
|
||||||
|
|
||||||
|
|
||||||
def lower(x):
|
def lower(x: str) -> str:
|
||||||
return x.lower()
|
return x.lower()
|
||||||
|
|
||||||
|
|
||||||
class INIConfig(config.ConfigNamespace):
|
class INIConfig(config.ConfigNamespace):
|
||||||
_data = None
|
_data: LineContainer = None
|
||||||
_sections = None
|
_sections: Dict[str, object] = None
|
||||||
_defaults = None
|
_defaults: INISection = None
|
||||||
_optionxformvalue = None
|
_optionxformvalue: Callable = None
|
||||||
_optionxformsource = None
|
_optionxformsource: Optional["INIConfig"] = None
|
||||||
_sectionxformvalue = None
|
_sectionxformvalue: Optional["INIConfig"] = None
|
||||||
_sectionxformsource = None
|
_sectionxformsource: Optional["INIConfig"] = None
|
||||||
_parse_exc = None
|
_parse_exc = None
|
||||||
_bom = False
|
_bom = False
|
||||||
|
|
||||||
def __init__(self, fp=None, defaults=None, parse_exc=True,
|
def __init__(
|
||||||
optionxformvalue=lower, optionxformsource=None,
|
self,
|
||||||
sectionxformvalue=None, sectionxformsource=None):
|
fp: TextIO = None,
|
||||||
|
defaults: Dict[str, object] = None,
|
||||||
|
parse_exc: bool = True,
|
||||||
|
optionxformvalue: Callable = lower,
|
||||||
|
optionxformsource: Optional[Union["INIConfig", "RawConfigParser"]] = None,
|
||||||
|
sectionxformvalue: Optional["INIConfig"] = None,
|
||||||
|
sectionxformsource: Optional["INIConfig"] = None,
|
||||||
|
) -> None:
|
||||||
self._data = LineContainer()
|
self._data = LineContainer()
|
||||||
self._parse_exc = parse_exc
|
self._parse_exc = parse_exc
|
||||||
self._optionxformvalue = optionxformvalue
|
self._optionxformvalue = optionxformvalue
|
||||||
self._optionxformsource = optionxformsource
|
self._optionxformsource = optionxformsource
|
||||||
self._sectionxformvalue = sectionxformvalue
|
self._sectionxformvalue = sectionxformvalue
|
||||||
self._sectionxformsource = sectionxformsource
|
self._sectionxformsource = sectionxformsource
|
||||||
self._sections = {}
|
self._sections: Dict[str, INISection] = {}
|
||||||
if defaults is None: defaults = {}
|
if defaults is None:
|
||||||
|
defaults = {}
|
||||||
self._defaults = INISection(LineContainer(), optionxformsource=self)
|
self._defaults = INISection(LineContainer(), optionxformsource=self)
|
||||||
for name, value in defaults.items():
|
for name, value in defaults.items():
|
||||||
self._defaults[name] = value
|
self._defaults[name] = value
|
||||||
if fp is not None:
|
if fp is not None:
|
||||||
self._readfp(fp)
|
self._readfp(fp)
|
||||||
|
|
||||||
_optionxform = _make_xform_property('_optionxform', 'optionxform')
|
_optionxform = _make_xform_property("_optionxform", "optionxform")
|
||||||
_sectionxform = _make_xform_property('_sectionxform', 'optionxform')
|
_sectionxform = _make_xform_property("_sectionxform", "optionxform")
|
||||||
|
|
||||||
def _getitem(self, key):
|
def _getitem(self, key: str) -> INISection:
|
||||||
if key == DEFAULTSECT:
|
if key == DEFAULTSECT:
|
||||||
return self._defaults
|
return self._defaults
|
||||||
if self._sectionxform: key = self._sectionxform(key)
|
if self._sectionxform:
|
||||||
|
key = self._sectionxform(key)
|
||||||
return self._sections[key]
|
return self._sections[key]
|
||||||
|
|
||||||
def __setitem__(self, key, value):
|
def __setitem__(self, key: str, value: object):
|
||||||
raise Exception('Values must be inside sections', key, value)
|
raise Exception("Values must be inside sections", key, value)
|
||||||
|
|
||||||
def __delitem__(self, key):
|
def __delitem__(self, key: str) -> None:
|
||||||
if self._sectionxform: key = self._sectionxform(key)
|
if self._sectionxform:
|
||||||
|
key = self._sectionxform(key)
|
||||||
for line in self._sections[key]._lines:
|
for line in self._sections[key]._lines:
|
||||||
self._data.contents.remove(line)
|
self._data.contents.remove(line)
|
||||||
del self._sections[key]
|
del self._sections[key]
|
||||||
|
|
||||||
def __iter__(self):
|
def __iter__(self) -> Iterator[str]:
|
||||||
d = set()
|
d = set()
|
||||||
d.add(DEFAULTSECT)
|
d.add(DEFAULTSECT)
|
||||||
for x in self._data.contents:
|
for x in self._data.contents:
|
||||||
@@ -507,35 +545,31 @@ class INIConfig(config.ConfigNamespace):
|
|||||||
yield x.name
|
yield x.name
|
||||||
d.add(x.name)
|
d.add(x.name)
|
||||||
|
|
||||||
def _new_namespace(self, name):
|
def _new_namespace(self, name: str) -> INISection:
|
||||||
if self._data.contents:
|
if self._data.contents:
|
||||||
self._data.add(EmptyLine())
|
self._data.add(EmptyLine())
|
||||||
obj = LineContainer(SectionLine(name))
|
obj = LineContainer(SectionLine(name))
|
||||||
self._data.add(obj)
|
self._data.add(obj)
|
||||||
if self._sectionxform: name = self._sectionxform(name)
|
if self._sectionxform:
|
||||||
|
name = self._sectionxform(name)
|
||||||
if name in self._sections:
|
if name in self._sections:
|
||||||
ns = self._sections[name]
|
ns = self._sections[name]
|
||||||
ns._lines.append(obj)
|
ns._lines.append(obj)
|
||||||
else:
|
else:
|
||||||
ns = INISection(obj, defaults=self._defaults,
|
ns = INISection(obj, defaults=self._defaults, optionxformsource=self)
|
||||||
optionxformsource=self)
|
|
||||||
self._sections[name] = ns
|
self._sections[name] = ns
|
||||||
return ns
|
return ns
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
if self._bom:
|
if self._bom:
|
||||||
fmt = u'\ufeff%s'
|
fmt = "\ufeff%s"
|
||||||
else:
|
else:
|
||||||
fmt = '%s'
|
fmt = "%s"
|
||||||
return fmt % self._data.__str__()
|
return fmt % self._data.__str__()
|
||||||
|
|
||||||
__unicode__ = __str__
|
_line_types = [EmptyLine, CommentLine, SectionLine, OptionLine, ContinuationLine]
|
||||||
|
|
||||||
_line_types = [EmptyLine, CommentLine,
|
def _parse(self, line: str) -> Any:
|
||||||
SectionLine, OptionLine,
|
|
||||||
ContinuationLine]
|
|
||||||
|
|
||||||
def _parse(self, line):
|
|
||||||
for linetype in self._line_types:
|
for linetype in self._line_types:
|
||||||
lineobj = linetype.parse(line)
|
lineobj = linetype.parse(line)
|
||||||
if lineobj:
|
if lineobj:
|
||||||
@@ -544,7 +578,7 @@ class INIConfig(config.ConfigNamespace):
|
|||||||
# can't parse line
|
# can't parse line
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _readfp(self, fp):
|
def _readfp(self, fp: TextIO) -> None:
|
||||||
cur_section = None
|
cur_section = None
|
||||||
cur_option = None
|
cur_option = None
|
||||||
cur_section_name = None
|
cur_section_name = None
|
||||||
@@ -554,21 +588,20 @@ class INIConfig(config.ConfigNamespace):
|
|||||||
try:
|
try:
|
||||||
fname = fp.name
|
fname = fp.name
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
fname = '<???>'
|
fname = "<???>"
|
||||||
line_count = 0
|
line_count = 0
|
||||||
exc = None
|
exc = None
|
||||||
line = None
|
line = None
|
||||||
|
|
||||||
for line in readline_iterator(fp):
|
for line in readline_iterator(fp):
|
||||||
# Check for BOM on first line
|
# Check for BOM on first line
|
||||||
if line_count == 0 and isinstance(line, six.text_type):
|
if line_count == 0 and isinstance(line, str):
|
||||||
if line[0] == u'\ufeff':
|
if line[0] == "\ufeff":
|
||||||
line = line[1:]
|
line = line[1:]
|
||||||
self._bom = True
|
self._bom = True
|
||||||
|
|
||||||
line_obj = self._parse(line)
|
line_obj = self._parse(line)
|
||||||
line_count += 1
|
line_count += 1
|
||||||
|
|
||||||
if not cur_section and not isinstance(line_obj, (CommentLine, EmptyLine, SectionLine)):
|
if not cur_section and not isinstance(line_obj, (CommentLine, EmptyLine, SectionLine)):
|
||||||
if self._parse_exc:
|
if self._parse_exc:
|
||||||
raise MissingSectionHeaderError(fname, line_count, line)
|
raise MissingSectionHeaderError(fname, line_count, line)
|
||||||
@@ -588,7 +621,7 @@ class INIConfig(config.ConfigNamespace):
|
|||||||
cur_option.extend(pending_lines)
|
cur_option.extend(pending_lines)
|
||||||
pending_lines = []
|
pending_lines = []
|
||||||
if pending_empty_lines:
|
if pending_empty_lines:
|
||||||
optobj._compat_skip_empty_lines.add(cur_option_name)
|
optobj._compat_skip_empty_lines.add(cur_option_name) # noqa : F821
|
||||||
pending_empty_lines = False
|
pending_empty_lines = False
|
||||||
cur_option.add(line_obj)
|
cur_option.add(line_obj)
|
||||||
else:
|
else:
|
||||||
@@ -633,9 +666,7 @@ class INIConfig(config.ConfigNamespace):
|
|||||||
else:
|
else:
|
||||||
cur_section_name = cur_section.name
|
cur_section_name = cur_section.name
|
||||||
if cur_section_name not in self._sections:
|
if cur_section_name not in self._sections:
|
||||||
self._sections[cur_section_name] = \
|
self._sections[cur_section_name] = INISection(cur_section, defaults=self._defaults, optionxformsource=self)
|
||||||
INISection(cur_section, defaults=self._defaults,
|
|
||||||
optionxformsource=self)
|
|
||||||
else:
|
else:
|
||||||
self._sections[cur_section_name]._lines.append(cur_section)
|
self._sections[cur_section_name]._lines.append(cur_section)
|
||||||
|
|
||||||
@@ -644,8 +675,11 @@ class INIConfig(config.ConfigNamespace):
|
|||||||
if isinstance(line_obj, EmptyLine):
|
if isinstance(line_obj, EmptyLine):
|
||||||
pending_empty_lines = True
|
pending_empty_lines = True
|
||||||
|
|
||||||
|
if line_obj:
|
||||||
|
line_obj.line_number = line_count
|
||||||
|
|
||||||
self._data.extend(pending_lines)
|
self._data.extend(pending_lines)
|
||||||
if line and line[-1] == '\n':
|
if line and line[-1] == "\n":
|
||||||
self._data.add(EmptyLine())
|
self._data.add(EmptyLine())
|
||||||
|
|
||||||
if exc:
|
if exc:
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
|
from typing import TYPE_CHECKING, List
|
||||||
|
|
||||||
from . import compat
|
from . import compat
|
||||||
from .ini import LineContainer, EmptyLine
|
from .ini import EmptyLine, LineContainer
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .ini import LineType
|
||||||
|
|
||||||
|
|
||||||
def tidy(cfg):
|
def tidy(cfg: compat.RawConfigParser):
|
||||||
"""Clean up blank lines.
|
"""Clean up blank lines.
|
||||||
|
|
||||||
This functions makes the configuration look clean and
|
This functions makes the configuration look clean and
|
||||||
@@ -19,8 +24,7 @@ def tidy(cfg):
|
|||||||
if isinstance(cont[i], LineContainer):
|
if isinstance(cont[i], LineContainer):
|
||||||
tidy_section(cont[i])
|
tidy_section(cont[i])
|
||||||
i += 1
|
i += 1
|
||||||
elif (isinstance(cont[i-1], EmptyLine) and
|
elif isinstance(cont[i - 1], EmptyLine) and isinstance(cont[i], EmptyLine):
|
||||||
isinstance(cont[i], EmptyLine)):
|
|
||||||
del cont[i]
|
del cont[i]
|
||||||
else:
|
else:
|
||||||
i += 1
|
i += 1
|
||||||
@@ -34,11 +38,11 @@ def tidy(cfg):
|
|||||||
cont.append(EmptyLine())
|
cont.append(EmptyLine())
|
||||||
|
|
||||||
|
|
||||||
def tidy_section(lc):
|
def tidy_section(lc: "LineContainer"):
|
||||||
cont = lc.contents
|
cont: List[LineType] = lc.contents
|
||||||
i = 1
|
i: int = 1
|
||||||
while i < len(cont):
|
while i < len(cont):
|
||||||
if isinstance(cont[i-1], EmptyLine) and isinstance(cont[i], EmptyLine):
|
if isinstance(cont[i - 1], EmptyLine) and isinstance(cont[i], EmptyLine):
|
||||||
del cont[i]
|
del cont[i]
|
||||||
else:
|
else:
|
||||||
i += 1
|
i += 1
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# This file is part of TISBackup
|
||||||
|
#
|
||||||
|
# TISBackup is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# TISBackup is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with TISBackup. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
"""Process execution and monitoring utilities."""
|
||||||
|
|
||||||
|
import errno
|
||||||
|
import os
|
||||||
|
import select
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
|
def call_external_process(shell_string):
|
||||||
|
"""Execute a shell command and raise exception on non-zero exit code."""
|
||||||
|
p = subprocess.call(shell_string, shell=True)
|
||||||
|
if p != 0:
|
||||||
|
raise Exception("shell program exited with error code " + str(p), shell_string)
|
||||||
|
|
||||||
|
|
||||||
|
def monitor_stdout(aprocess, onoutputdata, context):
|
||||||
|
"""Reads data from stdout and stderr from aprocess and return as a string
|
||||||
|
on each chunk, call a call back onoutputdata(dataread)
|
||||||
|
"""
|
||||||
|
assert isinstance(aprocess, subprocess.Popen)
|
||||||
|
read_set = []
|
||||||
|
stdout = []
|
||||||
|
line = ""
|
||||||
|
|
||||||
|
if aprocess.stdout:
|
||||||
|
read_set.append(aprocess.stdout)
|
||||||
|
if aprocess.stderr:
|
||||||
|
read_set.append(aprocess.stderr)
|
||||||
|
|
||||||
|
while read_set:
|
||||||
|
try:
|
||||||
|
rlist, wlist, xlist = select.select(read_set, [], [])
|
||||||
|
except select.error as e:
|
||||||
|
if e.args[0] == errno.EINTR:
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Reads one line from stdout
|
||||||
|
if aprocess.stdout in rlist:
|
||||||
|
data = os.read(aprocess.stdout.fileno(), 1)
|
||||||
|
data = data.decode(errors="ignore")
|
||||||
|
if data == "":
|
||||||
|
aprocess.stdout.close()
|
||||||
|
read_set.remove(aprocess.stdout)
|
||||||
|
while data and data not in ("\n", "\r"):
|
||||||
|
line += data
|
||||||
|
data = os.read(aprocess.stdout.fileno(), 1)
|
||||||
|
data = data.decode(errors="ignore")
|
||||||
|
if line or data in ("\n", "\r"):
|
||||||
|
stdout.append(line)
|
||||||
|
if onoutputdata:
|
||||||
|
onoutputdata(line, context)
|
||||||
|
line = ""
|
||||||
|
|
||||||
|
# Reads one line from stderr
|
||||||
|
if aprocess.stderr in rlist:
|
||||||
|
data = os.read(aprocess.stderr.fileno(), 1)
|
||||||
|
data = data.decode(errors="ignore")
|
||||||
|
if data == "":
|
||||||
|
aprocess.stderr.close()
|
||||||
|
read_set.remove(aprocess.stderr)
|
||||||
|
while data and data not in ("\n", "\r"):
|
||||||
|
line += data
|
||||||
|
data = os.read(aprocess.stderr.fileno(), 1)
|
||||||
|
data = data.decode(errors="ignore")
|
||||||
|
if line or data in ("\n", "\r"):
|
||||||
|
stdout.append(line)
|
||||||
|
if onoutputdata:
|
||||||
|
onoutputdata(line, context)
|
||||||
|
line = ""
|
||||||
|
|
||||||
|
aprocess.wait()
|
||||||
|
if line:
|
||||||
|
stdout.append(line)
|
||||||
|
if onoutputdata:
|
||||||
|
onoutputdata(line, context)
|
||||||
|
return "\n".join(stdout)
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# This file is part of TISBackup
|
||||||
|
#
|
||||||
|
# TISBackup is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# TISBackup is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with TISBackup. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
"""SSH operations and key management utilities."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
sys.stderr = open("/dev/null") # Silence silly warnings from paramiko
|
||||||
|
import paramiko
|
||||||
|
except ImportError as e:
|
||||||
|
print(("Error : can not load paramiko library %s" % e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
sys.stderr = sys.__stderr__
|
||||||
|
|
||||||
|
|
||||||
|
def load_ssh_private_key(private_key_path):
|
||||||
|
"""Load SSH private key with modern algorithm support.
|
||||||
|
|
||||||
|
Tries to load the key in order of preference:
|
||||||
|
1. Ed25519 (most secure, modern)
|
||||||
|
2. ECDSA (secure, widely supported)
|
||||||
|
3. RSA (legacy, still secure with sufficient key size)
|
||||||
|
|
||||||
|
DSA is not supported as it's deprecated and insecure.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
private_key_path: Path to the private key file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
paramiko key object
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
paramiko.SSHException: If key cannot be loaded
|
||||||
|
"""
|
||||||
|
key_types = [
|
||||||
|
("Ed25519", paramiko.Ed25519Key),
|
||||||
|
("ECDSA", paramiko.ECDSAKey),
|
||||||
|
("RSA", paramiko.RSAKey),
|
||||||
|
]
|
||||||
|
|
||||||
|
last_exception = None
|
||||||
|
for key_name, key_class in key_types:
|
||||||
|
try:
|
||||||
|
return key_class.from_private_key_file(private_key_path)
|
||||||
|
except paramiko.SSHException as e:
|
||||||
|
last_exception = e
|
||||||
|
continue
|
||||||
|
|
||||||
|
# If we get here, none of the key types worked
|
||||||
|
raise paramiko.SSHException(
|
||||||
|
f"Unable to load private key from {private_key_path}. "
|
||||||
|
f"Supported formats: Ed25519 (recommended), ECDSA, RSA. "
|
||||||
|
f"DSA keys are no longer supported. "
|
||||||
|
f"Last error: {last_exception}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ssh_exec(command, ssh=None, server_name="", remote_user="", private_key="", ssh_port=22):
|
||||||
|
"""execute command on server_name using the provided ssh connection
|
||||||
|
or creates a new connection if ssh is not provided.
|
||||||
|
returns (exit_code,output)
|
||||||
|
|
||||||
|
output is the concatenation of stdout and stderr
|
||||||
|
"""
|
||||||
|
if not ssh:
|
||||||
|
assert server_name and remote_user and private_key
|
||||||
|
mykey = load_ssh_private_key(private_key)
|
||||||
|
|
||||||
|
ssh = paramiko.SSHClient()
|
||||||
|
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
ssh.connect(server_name, username=remote_user, pkey=mykey, port=ssh_port)
|
||||||
|
|
||||||
|
tran = ssh.get_transport()
|
||||||
|
chan = tran.open_session()
|
||||||
|
|
||||||
|
# chan.set_combine_stderr(True)
|
||||||
|
chan.get_pty()
|
||||||
|
stdout = chan.makefile()
|
||||||
|
|
||||||
|
chan.exec_command(command)
|
||||||
|
stdout.flush()
|
||||||
|
output_base = stdout.read()
|
||||||
|
output = output_base.decode(errors="ignore").replace("'", "")
|
||||||
|
exit_code = chan.recv_exit_status()
|
||||||
|
return (exit_code, output)
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# This file is part of TISBackup
|
||||||
|
#
|
||||||
|
# TISBackup is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# TISBackup is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with TISBackup. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
"""Utility functions for date/time formatting, number formatting, and display helpers."""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def datetime2isodate(adatetime=None):
|
||||||
|
"""Convert datetime to ISO format string."""
|
||||||
|
if not adatetime:
|
||||||
|
adatetime = datetime.datetime.now()
|
||||||
|
assert isinstance(adatetime, datetime.datetime)
|
||||||
|
return adatetime.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def isodate2datetime(isodatestr):
|
||||||
|
"""Convert ISO format string to datetime."""
|
||||||
|
# we remove the microseconds part as it is not working for python2.5 strptime
|
||||||
|
return datetime.datetime.strptime(isodatestr.split(".")[0], "%Y-%m-%dT%H:%M:%S")
|
||||||
|
|
||||||
|
|
||||||
|
def time2display(adatetime):
|
||||||
|
"""Format datetime for display."""
|
||||||
|
return adatetime.strftime("%Y-%m-%d %H:%M")
|
||||||
|
|
||||||
|
|
||||||
|
def hours_minutes(hours):
|
||||||
|
"""Convert decimal hours to HH:MM format."""
|
||||||
|
if hours is None:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return "%02i:%02i" % (int(hours), int((hours - int(hours)) * 60.0))
|
||||||
|
|
||||||
|
|
||||||
|
def fileisodate(filename):
|
||||||
|
"""Get file modification time as ISO date string."""
|
||||||
|
return datetime.datetime.fromtimestamp(os.stat(filename).st_mtime).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def dateof(adatetime):
|
||||||
|
"""Get date part of datetime (midnight)."""
|
||||||
|
return adatetime.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
|
||||||
|
#####################################
|
||||||
|
# http://code.activestate.com/recipes/498181-add-thousands-separator-commas-to-formatted-number/
|
||||||
|
# Code from Michael Robellard's comment made 28 Feb 2010
|
||||||
|
# Modified for leading +, -, space on 1 Mar 2010 by Glenn Linderman
|
||||||
|
#
|
||||||
|
# Tail recursion removed and leading garbage handled on March 12 2010, Alessandro Forghieri
|
||||||
|
def splitThousands(s, tSep=",", dSep="."):
|
||||||
|
"""Splits a general float on thousands. GIGO on general input"""
|
||||||
|
if s is None:
|
||||||
|
return 0
|
||||||
|
if not isinstance(s, str):
|
||||||
|
s = str(s)
|
||||||
|
|
||||||
|
cnt = 0
|
||||||
|
numChars = dSep + "0123456789"
|
||||||
|
ls = len(s)
|
||||||
|
while cnt < ls and s[cnt] not in numChars:
|
||||||
|
cnt += 1
|
||||||
|
|
||||||
|
lhs = s[0:cnt]
|
||||||
|
s = s[cnt:]
|
||||||
|
if dSep == "":
|
||||||
|
cnt = -1
|
||||||
|
else:
|
||||||
|
cnt = s.rfind(dSep)
|
||||||
|
if cnt > 0:
|
||||||
|
rhs = dSep + s[cnt + 1 :]
|
||||||
|
s = s[:cnt]
|
||||||
|
else:
|
||||||
|
rhs = ""
|
||||||
|
|
||||||
|
splt = ""
|
||||||
|
while s != "":
|
||||||
|
splt = s[-3:] + tSep + splt
|
||||||
|
s = s[:-3]
|
||||||
|
|
||||||
|
return lhs + splt[:-1] + rhs
|
||||||
|
|
||||||
|
|
||||||
|
def convert_bytes(bytes):
|
||||||
|
"""Convert bytes to human-readable format (T/G/M/K/b)."""
|
||||||
|
if bytes is None:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
bytes = float(bytes)
|
||||||
|
if bytes >= 1099511627776:
|
||||||
|
terabytes = bytes / 1099511627776
|
||||||
|
size = "%.2fT" % terabytes
|
||||||
|
elif bytes >= 1073741824:
|
||||||
|
gigabytes = bytes / 1073741824
|
||||||
|
size = "%.2fG" % gigabytes
|
||||||
|
elif bytes >= 1048576:
|
||||||
|
megabytes = bytes / 1048576
|
||||||
|
size = "%.2fM" % megabytes
|
||||||
|
elif bytes >= 1024:
|
||||||
|
kilobytes = bytes / 1024
|
||||||
|
size = "%.2fK" % kilobytes
|
||||||
|
else:
|
||||||
|
size = "%.2fb" % bytes
|
||||||
|
return size
|
||||||
|
|
||||||
|
|
||||||
|
def check_string(test_string):
|
||||||
|
"""Check if string contains only alphanumeric characters, dots, dashes, and underscores."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
pattern = r"[^\.A-Za-z0-9\-_]"
|
||||||
|
if re.search(pattern, test_string):
|
||||||
|
# Character other then . a-z 0-9 was found
|
||||||
|
print(("Invalid : %r" % (test_string,)))
|
||||||
|
|
||||||
|
|
||||||
|
def str2bool(val):
|
||||||
|
"""Convert string to boolean."""
|
||||||
|
if not isinstance(type(val), bool):
|
||||||
|
return val.lower() in ("yes", "true", "t", "1")
|
||||||
|
|
||||||
|
|
||||||
|
## {{{ http://code.activestate.com/recipes/81189/ (r2)
|
||||||
|
def pp(cursor, data=None, rowlens=0, callback=None):
|
||||||
|
"""
|
||||||
|
pretty print a query result as a table
|
||||||
|
callback is a function called for each field (fieldname,value) to format the output
|
||||||
|
"""
|
||||||
|
|
||||||
|
def defaultcb(fieldname, value):
|
||||||
|
return value
|
||||||
|
|
||||||
|
if not callback:
|
||||||
|
callback = defaultcb
|
||||||
|
|
||||||
|
d = cursor.description
|
||||||
|
if not d:
|
||||||
|
return "#### NO RESULTS ###"
|
||||||
|
names = []
|
||||||
|
lengths = []
|
||||||
|
rules = []
|
||||||
|
if not data:
|
||||||
|
data = cursor.fetchall()
|
||||||
|
for dd in d: # iterate over description
|
||||||
|
l = dd[1]
|
||||||
|
if not l:
|
||||||
|
l = 12 # or default arg ...
|
||||||
|
l = max(l, len(dd[0])) # handle long names
|
||||||
|
names.append(dd[0])
|
||||||
|
lengths.append(l)
|
||||||
|
for col in range(len(lengths)):
|
||||||
|
if rowlens:
|
||||||
|
rls = [len(str(callback(d[col][0], row[col]))) for row in data if row[col]]
|
||||||
|
lengths[col] = max([lengths[col]] + rls)
|
||||||
|
rules.append("-" * lengths[col])
|
||||||
|
format = " ".join(["%%-%ss" % l for l in lengths])
|
||||||
|
result = [format % tuple(names)]
|
||||||
|
result.append(format % tuple(rules))
|
||||||
|
for row in data:
|
||||||
|
row_cb = []
|
||||||
|
for col in range(len(d)):
|
||||||
|
row_cb.append(callback(d[col][0], row[col]))
|
||||||
|
result.append(format % tuple(row_cb))
|
||||||
|
return "\n".join(result)
|
||||||
|
|
||||||
|
|
||||||
|
## end of http://code.activestate.com/recipes/81189/ }}}
|
||||||
|
|
||||||
|
|
||||||
|
def html_table(cur, callback=None):
|
||||||
|
"""
|
||||||
|
cur est un cursor issu d'une requete
|
||||||
|
callback est une fonction qui prend (rowmap,fieldname,value)
|
||||||
|
et renvoie une representation texte
|
||||||
|
"""
|
||||||
|
|
||||||
|
def safe_unicode(iso):
|
||||||
|
if iso is None:
|
||||||
|
return None
|
||||||
|
elif isinstance(iso, str):
|
||||||
|
return iso # .decode()
|
||||||
|
else:
|
||||||
|
return iso
|
||||||
|
|
||||||
|
def itermap(cur):
|
||||||
|
for row in cur:
|
||||||
|
yield dict((cur.description[idx][0], value) for idx, value in enumerate(row))
|
||||||
|
|
||||||
|
head = "<tr>" + "".join(["<th>" + c[0] + "</th>" for c in cur.description]) + "</tr>"
|
||||||
|
lines = ""
|
||||||
|
if callback:
|
||||||
|
for r in itermap(cur):
|
||||||
|
lines = (
|
||||||
|
lines
|
||||||
|
+ "<tr>"
|
||||||
|
+ "".join(["<td>" + str(callback(r, c[0], safe_unicode(r[c[0]]))) + "</td>" for c in cur.description])
|
||||||
|
+ "</tr>"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for r in cur:
|
||||||
|
lines = lines + "<tr>" + "".join(["<td>" + safe_unicode(c) + "</td>" for c in r]) + "</tr>"
|
||||||
|
|
||||||
|
return "<table border=1 cellpadding=2 cellspacing=0>%s%s</table>" % (head, lines)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
# Remove '#' in the next line to enable IPv6
|
||||||
|
# listen [::]:443 ssl http2;
|
||||||
|
server_name tisbackup.poudlard.lan;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/tisbackup.poudlard.lan/fullchain.pem; # managed by Certbot
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/tisbackup.poudlard.lan/privkey.pem; # managed by Certbot
|
||||||
|
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_pass http://localhost:9980/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
}
|
||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
[project]
|
||||||
|
name = "TISbackup"
|
||||||
|
version = "1.8.0"
|
||||||
|
description = "Backup server side executed python scripts for managing linux and windows system and application data backups, developed by adminsys for adminsys"
|
||||||
|
readme = "README.md"
|
||||||
|
dependencies = [
|
||||||
|
"authlib>=1.3.0",
|
||||||
|
"bcrypt>=4.0.0",
|
||||||
|
"flask==3.1.0",
|
||||||
|
"flask-login>=0.6.0",
|
||||||
|
"huey==2.5.3",
|
||||||
|
"iniparse==0.5",
|
||||||
|
"paramiko==4.0.0",
|
||||||
|
"peewee==3.17.9",
|
||||||
|
"pexpect==4.9.0",
|
||||||
|
"pyvmomi>=8.0.0",
|
||||||
|
"redis==5.2.1",
|
||||||
|
"requests==2.32.3",
|
||||||
|
"ruff>=0.13.3",
|
||||||
|
"simplejson==3.20.1",
|
||||||
|
"six==1.17.0",
|
||||||
|
]
|
||||||
|
requires-python = ">=3.14"
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
# Documentation dependencies
|
||||||
|
docs = [
|
||||||
|
"docutils",
|
||||||
|
"sphinx>=7.0.0,<8.0.0",
|
||||||
|
"sphinx_rtd_theme",
|
||||||
|
"sphinxjp.themes.revealjs",
|
||||||
|
"sphinx-intl",
|
||||||
|
"sphinx-tabs",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 140
|
||||||
|
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
# Allow lines to be as long as 120.
|
||||||
|
line-length = 140
|
||||||
|
indent-width = 4
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
ignore = ["F401", "F403", "F405", "E402", "E701", "E722", "E741"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
# Pytest configuration for TISBackup
|
||||||
|
|
||||||
|
# Test discovery patterns
|
||||||
|
python_files = ["test_*.py"]
|
||||||
|
python_classes = ["Test*"]
|
||||||
|
python_functions = ["test_*"]
|
||||||
|
|
||||||
|
# Test paths
|
||||||
|
testpaths = ["tests"]
|
||||||
|
|
||||||
|
# Output options
|
||||||
|
addopts = [
|
||||||
|
"-v",
|
||||||
|
"--strict-markers",
|
||||||
|
"--tb=short",
|
||||||
|
"--color=yes",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Markers for categorizing tests
|
||||||
|
markers = [
|
||||||
|
"unit: Unit tests for individual functions/methods",
|
||||||
|
"integration: Integration tests that test multiple components together",
|
||||||
|
"ssh: Tests related to SSH functionality",
|
||||||
|
"slow: Tests that take a long time to run",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Minimum Python version
|
||||||
|
minversion = "3.14"
|
||||||
|
|
||||||
|
# Coverage options (optional - uncomment when pytest-cov is installed)
|
||||||
|
# addopts = ["--cov=libtisbackup", "--cov-report=html", "--cov-report=term-missing"]
|
||||||
|
|
||||||
|
[tool.pylint.main]
|
||||||
|
# Maximum line length
|
||||||
|
max-line-length = 140
|
||||||
|
|
||||||
|
# Files or directories to skip
|
||||||
|
ignore = ["tests", ".venv", "__pycache__", ".pytest_cache", "build", "dist"]
|
||||||
|
|
||||||
|
[tool.pylint."messages control"]
|
||||||
|
# Disable specific warnings to align with ruff configuration
|
||||||
|
disable = [
|
||||||
|
"C0103", # invalid-name (similar to ruff E741)
|
||||||
|
"C0114", # missing-module-docstring
|
||||||
|
"C0115", # missing-class-docstring
|
||||||
|
"C0116", # missing-function-docstring
|
||||||
|
"R0902", # too-many-instance-attributes
|
||||||
|
"R0903", # too-few-public-methods
|
||||||
|
"R0913", # too-many-arguments
|
||||||
|
"R0914", # too-many-locals
|
||||||
|
"W0703", # broad-except (similar to ruff E722)
|
||||||
|
"W0719", # broad-exception-raised
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pylint.format]
|
||||||
|
# Indentation settings
|
||||||
|
indent-string = " "
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
# Source code to measure coverage for
|
||||||
|
source = ["libtisbackup"]
|
||||||
|
|
||||||
|
# Omit certain files
|
||||||
|
omit = [
|
||||||
|
"*/tests/*",
|
||||||
|
"*/__pycache__/*",
|
||||||
|
"*/site-packages/*",
|
||||||
|
"*/.venv/*",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
# Precision for coverage percentage
|
||||||
|
precision = 2
|
||||||
|
|
||||||
|
# Show lines that weren't covered
|
||||||
|
show_missing = true
|
||||||
|
|
||||||
|
# Skip files with no executable code
|
||||||
|
skip_empty = true
|
||||||
|
|
||||||
|
# Fail if coverage is below this percentage
|
||||||
|
# fail_under = 80
|
||||||
|
|
||||||
|
[tool.coverage.html]
|
||||||
|
# Directory for HTML coverage report
|
||||||
|
directory = "htmlcov"
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"pylint>=3.0.0",
|
||||||
|
"pytest>=8.4.2",
|
||||||
|
"pytest-cov>=6.0.0",
|
||||||
|
"pytest-mock>=3.15.1",
|
||||||
|
]
|
||||||
Regular → Executable
+15
-3
@@ -1,3 +1,15 @@
|
|||||||
six
|
authlib>=1.3.0
|
||||||
requests
|
bcrypt>=4.0.0
|
||||||
paramiko
|
flask==3.1.0
|
||||||
|
flask-login>=0.6.0
|
||||||
|
huey==2.5.3
|
||||||
|
iniparse==0.5
|
||||||
|
paramiko==4.0.0
|
||||||
|
peewee==3.17.9
|
||||||
|
pexpect==4.9.0
|
||||||
|
pyvmomi>=8.0.0
|
||||||
|
redis==5.2.1
|
||||||
|
requests==2.32.3
|
||||||
|
ruff>=0.13.3
|
||||||
|
simplejson==3.20.1
|
||||||
|
six==1.17.0
|
||||||
|
|||||||
@@ -12,4 +12,3 @@ echo $VERSION > __VERSION__
|
|||||||
|
|
||||||
rpmbuild -bb --buildroot $PWD/builddir -v --clean tis-tisbackup.spec
|
rpmbuild -bb --buildroot $PWD/builddir -v --clean tis-tisbackup.spec
|
||||||
cp RPMS/*/*.rpm .
|
cp RPMS/*/*.rpm .
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# TISBackup Authentication Configuration Examples
|
||||||
|
# Add to tisbackup_gui.ini under [authentication] section
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Option 1: No Authentication (NOT RECOMMENDED)
|
||||||
|
# ============================================
|
||||||
|
[authentication]
|
||||||
|
type = none
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Option 2: HTTP Basic Authentication
|
||||||
|
# ============================================
|
||||||
|
[authentication]
|
||||||
|
type = basic
|
||||||
|
username = admin
|
||||||
|
# Plain text password (NOT RECOMMENDED for production)
|
||||||
|
password = changeme
|
||||||
|
use_bcrypt = False
|
||||||
|
realm = TISBackup Admin
|
||||||
|
|
||||||
|
# RECOMMENDED: Use bcrypt hash
|
||||||
|
# Generate hash with: python3 -c "import bcrypt; print(bcrypt.hashpw(b'yourpassword', bcrypt.gensalt()).decode())"
|
||||||
|
[authentication]
|
||||||
|
type = basic
|
||||||
|
username = admin
|
||||||
|
password = $2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYWv.5qVQK6
|
||||||
|
use_bcrypt = True
|
||||||
|
realm = TISBackup Admin
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Option 3: Flask-Login (Username/Password with Sessions)
|
||||||
|
# ============================================
|
||||||
|
[authentication]
|
||||||
|
type = flask-login
|
||||||
|
# Users can be defined inline or in a file
|
||||||
|
users_file = /etc/tis/users.txt
|
||||||
|
use_bcrypt = True
|
||||||
|
login_view = login
|
||||||
|
|
||||||
|
# User file format (users.txt):
|
||||||
|
# username:bcrypt_password_hash
|
||||||
|
# Example:
|
||||||
|
# admin:$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYWv.5qVQK6
|
||||||
|
# operator:$2b$12$abcdefghijklmnopqrstuvwxyz1234567890ABCDEFGHIJKLMNO
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Option 4: OAuth2 - Google
|
||||||
|
# ============================================
|
||||||
|
[authentication]
|
||||||
|
type = oauth
|
||||||
|
provider = google
|
||||||
|
client_id = your-client-id.apps.googleusercontent.com
|
||||||
|
client_secret = your-client-secret
|
||||||
|
redirect_uri = http://localhost:8080/oauth/callback
|
||||||
|
# Restrict to specific domains
|
||||||
|
authorized_domains = example.com,mycompany.com
|
||||||
|
# Or restrict to specific users
|
||||||
|
authorized_users = admin@example.com,backup-admin@example.com
|
||||||
|
|
||||||
|
# To get Google OAuth credentials:
|
||||||
|
# 1. Go to https://console.cloud.google.com/apis/credentials
|
||||||
|
# 2. Create OAuth 2.0 Client ID
|
||||||
|
# 3. Add authorized redirect URI: http://your-server:8080/oauth/callback
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Option 5: OAuth2 - GitHub
|
||||||
|
# ============================================
|
||||||
|
[authentication]
|
||||||
|
type = oauth
|
||||||
|
provider = github
|
||||||
|
client_id = your-github-client-id
|
||||||
|
client_secret = your-github-client-secret
|
||||||
|
redirect_uri = http://localhost:8080/oauth/callback
|
||||||
|
# Restrict to specific GitHub users (by email)
|
||||||
|
authorized_users = admin@example.com
|
||||||
|
|
||||||
|
# To get GitHub OAuth credentials:
|
||||||
|
# 1. Go to Settings > Developer settings > OAuth Apps
|
||||||
|
# 2. Register a new application
|
||||||
|
# 3. Set Authorization callback URL: http://your-server:8080/oauth/callback
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Option 6: OAuth2 - GitLab
|
||||||
|
# ============================================
|
||||||
|
[authentication]
|
||||||
|
type = oauth
|
||||||
|
provider = gitlab
|
||||||
|
client_id = your-gitlab-application-id
|
||||||
|
client_secret = your-gitlab-secret
|
||||||
|
redirect_uri = http://localhost:8080/oauth/callback
|
||||||
|
authorized_domains = example.com
|
||||||
|
|
||||||
|
# To get GitLab OAuth credentials:
|
||||||
|
# 1. Go to User Settings > Applications
|
||||||
|
# 2. Create new application with scopes: read_user, email
|
||||||
|
# 3. Set Redirect URI: http://your-server:8080/oauth/callback
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Option 7: OAuth2 - Generic Provider
|
||||||
|
# ============================================
|
||||||
|
[authentication]
|
||||||
|
type = oauth
|
||||||
|
provider = generic
|
||||||
|
client_id = your-client-id
|
||||||
|
client_secret = your-client-secret
|
||||||
|
redirect_uri = http://localhost:8080/oauth/callback
|
||||||
|
# Custom OAuth endpoints
|
||||||
|
authorization_endpoint = https://auth.example.com/oauth/authorize
|
||||||
|
token_endpoint = https://auth.example.com/oauth/token
|
||||||
|
userinfo_endpoint = https://auth.example.com/oauth/userinfo
|
||||||
|
scopes = openid,email,profile
|
||||||
|
authorized_domains = example.com
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Security Notes
|
||||||
|
# ============================================
|
||||||
|
# 1. Always use HTTPS in production (reverse proxy with TLS)
|
||||||
|
# 2. Set strong Flask secret_key via TISBACKUP_SECRET_KEY env var
|
||||||
|
# 3. For Basic Auth, always use bcrypt hashed passwords
|
||||||
|
# 4. For OAuth, restrict access via authorized_domains or authorized_users
|
||||||
|
# 5. Keep client secrets secure and never commit to version control
|
||||||
|
# 6. Regularly rotate OAuth client secrets
|
||||||
|
# 7. Use environment variables for sensitive data when possible
|
||||||
@@ -14,5 +14,3 @@ else
|
|||||||
sleep 3
|
sleep 3
|
||||||
fi
|
fi
|
||||||
echo $(date +%Y-%m-%d\ %H:%M:%S) : Fin Export TISBackup sur Disque USB : $target >> /var/log/tisbackup.log
|
echo $(date +%Y-%m-%d\ %H:%M:%S) : Fin Export TISBackup sur Disque USB : $target >> /var/log/tisbackup.log
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -95,4 +95,3 @@ maximum_backup_age=30
|
|||||||
;type=xcp-dump-metadata
|
;type=xcp-dump-metadata
|
||||||
;server_name=srvxen1
|
;server_name=srvxen1
|
||||||
;private_key=/root/.ssh/id_rsa
|
;private_key=/root/.ssh/id_rsa
|
||||||
|
|
||||||
|
|||||||
@@ -18,4 +18,3 @@ password_file=/home/homes/ssamson/tisbackup-pra/xen_passwd
|
|||||||
network_name=net-test
|
network_name=net-test
|
||||||
#start_vm=no
|
#start_vm=no
|
||||||
#max_copies=3
|
#max_copies=3
|
||||||
|
|
||||||
|
|||||||
@@ -4,4 +4,3 @@
|
|||||||
# m h dom mon dow user command
|
# m h dom mon dow user command
|
||||||
30 22 * * * root /opt/tisbackup/tisbackup.py -c /etc/tis/tisbackup-config.ini backup >> /var/log/tisbackup.log 2>&1
|
30 22 * * * root /opt/tisbackup/tisbackup.py -c /etc/tis/tisbackup-config.ini backup >> /var/log/tisbackup.log 2>&1
|
||||||
30 12 * * * root /opt/tisbackup/tisbackup.py -c /etc/tis/tisbackup-config.ini cleanup >> /var/log/tisbackup.log 2>&1
|
30 12 * * * root /opt/tisbackup/tisbackup.py -c /etc/tis/tisbackup-config.ini cleanup >> /var/log/tisbackup.log 2>&1
|
||||||
|
|
||||||
|
|||||||
@@ -95,4 +95,3 @@ case "$1" in
|
|||||||
esac
|
esac
|
||||||
|
|
||||||
exit 0
|
exit 0
|
||||||
|
|
||||||
|
|||||||
@@ -95,4 +95,3 @@ case "$1" in
|
|||||||
esac
|
esac
|
||||||
|
|
||||||
exit 0
|
exit 0
|
||||||
|
|
||||||
|
|||||||
Vendored
-1
@@ -14948,4 +14948,3 @@
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
}(window, document));
|
}(window, document));
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
from huey import RedisHuey
|
|
||||||
import os
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from huey import RedisHuey
|
||||||
|
|
||||||
from tisbackup import tis_backup
|
from tisbackup import tis_backup
|
||||||
|
|
||||||
huey = RedisHuey('tisbackup', host='localhost')
|
huey = RedisHuey("tisbackup", host="localhost")
|
||||||
|
|
||||||
|
|
||||||
@huey.task()
|
@huey.task()
|
||||||
def run_export_backup(base, config_file, mount_point, backup_sections):
|
def run_export_backup(base, config_file, mount_point, backup_sections):
|
||||||
try:
|
try:
|
||||||
#Log
|
# Log
|
||||||
logger = logging.getLogger('tisbackup')
|
logger = logging.getLogger("tisbackup")
|
||||||
logger.setLevel(logging.INFO)
|
logger.setLevel(logging.INFO)
|
||||||
formatter = logging.Formatter('%(asctime)s %(levelname)s %(message)s')
|
formatter = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
|
||||||
handler = logging.StreamHandler()
|
handler = logging.StreamHandler()
|
||||||
handler.setFormatter(formatter)
|
handler.setFormatter(formatter)
|
||||||
logger.addHandler(handler)
|
logger.addHandler(handler)
|
||||||
@@ -22,24 +27,30 @@ def run_export_backup(base, config_file, mount_point, backup_sections):
|
|||||||
backup_sections = backup_sections.split(",")
|
backup_sections = backup_sections.split(",")
|
||||||
else:
|
else:
|
||||||
backup_sections = []
|
backup_sections = []
|
||||||
backup = tis_backup(dry_run=False,verbose=True,backup_base_dir=base)
|
backup = tis_backup(dry_run=False, verbose=True, backup_base_dir=base)
|
||||||
backup.read_ini_file(config_file)
|
backup.read_ini_file(config_file)
|
||||||
mount_point = mount_point
|
mount_point = mount_point
|
||||||
backup.export_backups(backup_sections,mount_point)
|
backup.export_backups(backup_sections, mount_point)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return(str(e))
|
return str(e)
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
os.system("/bin/umount %s" % mount_point)
|
# Safely unmount using subprocess instead of os.system
|
||||||
os.rmdir(mount_point)
|
try:
|
||||||
|
subprocess.run(["/bin/umount", mount_point], check=True, timeout=30)
|
||||||
|
os.rmdir(mount_point)
|
||||||
|
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError) as e:
|
||||||
|
logger.error(f"Failed to unmount {mount_point}: {e}")
|
||||||
return "ok"
|
return "ok"
|
||||||
|
|
||||||
|
|
||||||
def get_task():
|
def get_task():
|
||||||
return task
|
return task
|
||||||
|
|
||||||
|
|
||||||
def set_task(my_task):
|
def set_task(my_task):
|
||||||
global task
|
global task
|
||||||
task = my_task
|
task = my_task
|
||||||
|
|
||||||
|
|
||||||
task = None
|
task = None
|
||||||
|
|||||||
@@ -98,4 +98,3 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
</body></html>
|
</body></html>
|
||||||
|
|
||||||
|
|||||||
+145
@@ -0,0 +1,145 @@
|
|||||||
|
# TISBackup Test Suite
|
||||||
|
|
||||||
|
This directory contains the test suite for TISBackup using pytest.
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
### Run all tests
|
||||||
|
```bash
|
||||||
|
uv run pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run tests for a specific module
|
||||||
|
```bash
|
||||||
|
uv run pytest tests/test_ssh.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run with verbose output
|
||||||
|
```bash
|
||||||
|
uv run pytest -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run tests matching a pattern
|
||||||
|
```bash
|
||||||
|
uv run pytest -k "ssh" -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run with coverage (requires pytest-cov)
|
||||||
|
```bash
|
||||||
|
uv run pytest --cov=libtisbackup --cov-report=html
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Structure
|
||||||
|
|
||||||
|
### Current Test Modules
|
||||||
|
|
||||||
|
- **[test_ssh.py](test_ssh.py)** - Tests for SSH operations module
|
||||||
|
- `TestLoadSSHPrivateKey` - Tests for key loading with Ed25519, ECDSA, and RSA support
|
||||||
|
- `TestSSHExec` - Tests for remote command execution via SSH
|
||||||
|
- `TestSSHModuleIntegration` - Integration tests for SSH functionality
|
||||||
|
|
||||||
|
## Test Categories
|
||||||
|
|
||||||
|
Tests are organized using pytest markers:
|
||||||
|
|
||||||
|
- `@pytest.mark.unit` - Unit tests for individual functions
|
||||||
|
- `@pytest.mark.integration` - Integration tests for multiple components
|
||||||
|
- `@pytest.mark.ssh` - SSH-related tests
|
||||||
|
- `@pytest.mark.slow` - Long-running tests
|
||||||
|
|
||||||
|
### Run only unit tests
|
||||||
|
```bash
|
||||||
|
uv run pytest -m unit
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run only SSH tests
|
||||||
|
```bash
|
||||||
|
uv run pytest -m ssh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Writing New Tests
|
||||||
|
|
||||||
|
### Test File Naming
|
||||||
|
- Test files should be named `test_*.py`
|
||||||
|
- Place them in the `tests/` directory
|
||||||
|
|
||||||
|
### Test Class Naming
|
||||||
|
- Test classes should start with `Test`
|
||||||
|
- Example: `TestMyModule`
|
||||||
|
|
||||||
|
### Test Function Naming
|
||||||
|
- Test functions should start with `test_`
|
||||||
|
- Use descriptive names: `test_load_ed25519_key_success`
|
||||||
|
|
||||||
|
### Example Test Structure
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
from libtisbackup.mymodule import my_function
|
||||||
|
|
||||||
|
class TestMyFunction:
|
||||||
|
"""Test cases for my_function."""
|
||||||
|
|
||||||
|
def test_basic_functionality(self):
|
||||||
|
"""Test basic use case."""
|
||||||
|
result = my_function("input")
|
||||||
|
assert result == "expected_output"
|
||||||
|
|
||||||
|
def test_error_handling(self):
|
||||||
|
"""Test error handling."""
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
my_function(None)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mocking
|
||||||
|
|
||||||
|
The test suite uses `pytest-mock` for mocking dependencies. Common patterns:
|
||||||
|
|
||||||
|
### Mocking with patch
|
||||||
|
```python
|
||||||
|
from unittest.mock import patch, Mock
|
||||||
|
|
||||||
|
def test_with_mock():
|
||||||
|
with patch('module.function') as mock_func:
|
||||||
|
mock_func.return_value = "mocked"
|
||||||
|
result = my_code()
|
||||||
|
assert result == "mocked"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using pytest fixtures
|
||||||
|
```python
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_ssh_client():
|
||||||
|
return Mock(spec=paramiko.SSHClient)
|
||||||
|
|
||||||
|
def test_with_fixture(mock_ssh_client):
|
||||||
|
# Use the fixture
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coverage Goals
|
||||||
|
|
||||||
|
Aim for:
|
||||||
|
- **80%+** overall code coverage
|
||||||
|
- **90%+** for critical modules (ssh, database, base_driver)
|
||||||
|
- **100%** for utility functions
|
||||||
|
|
||||||
|
## Test Configuration
|
||||||
|
|
||||||
|
Test configuration is in the `[tool.pytest.ini_options]` section of [pyproject.toml](../pyproject.toml):
|
||||||
|
- Test discovery patterns
|
||||||
|
- Output formatting
|
||||||
|
- Markers definition
|
||||||
|
- Minimum Python version
|
||||||
|
|
||||||
|
## Continuous Integration
|
||||||
|
|
||||||
|
Tests should pass before merging:
|
||||||
|
```bash
|
||||||
|
# Run linting
|
||||||
|
uv run ruff check .
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
uv run pytest -v
|
||||||
|
|
||||||
|
# Both must pass
|
||||||
|
```
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# This file is part of TISBackup
|
||||||
|
#
|
||||||
|
# TISBackup is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# TISBackup is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with TISBackup. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
"""
|
||||||
|
Test suite for libtisbackup.ssh module.
|
||||||
|
|
||||||
|
Tests SSH key loading and remote command execution functionality.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import paramiko
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from libtisbackup.ssh import load_ssh_private_key, ssh_exec
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadSSHPrivateKey:
|
||||||
|
"""Test cases for load_ssh_private_key() function."""
|
||||||
|
|
||||||
|
def test_load_ed25519_key_success(self):
|
||||||
|
"""Test loading a valid Ed25519 key."""
|
||||||
|
with patch.object(paramiko.Ed25519Key, "from_private_key_file") as mock_ed25519:
|
||||||
|
mock_key = Mock()
|
||||||
|
mock_ed25519.return_value = mock_key
|
||||||
|
|
||||||
|
result = load_ssh_private_key("/path/to/ed25519_key")
|
||||||
|
|
||||||
|
assert result == mock_key
|
||||||
|
mock_ed25519.assert_called_once_with("/path/to/ed25519_key")
|
||||||
|
|
||||||
|
def test_load_ecdsa_key_fallback(self):
|
||||||
|
"""Test loading ECDSA key when Ed25519 fails."""
|
||||||
|
with patch.object(paramiko.Ed25519Key, "from_private_key_file") as mock_ed25519, patch.object(
|
||||||
|
paramiko.ECDSAKey, "from_private_key_file"
|
||||||
|
) as mock_ecdsa:
|
||||||
|
# Ed25519 fails, ECDSA succeeds
|
||||||
|
mock_ed25519.side_effect = paramiko.SSHException("Not Ed25519")
|
||||||
|
mock_key = Mock()
|
||||||
|
mock_ecdsa.return_value = mock_key
|
||||||
|
|
||||||
|
result = load_ssh_private_key("/path/to/ecdsa_key")
|
||||||
|
|
||||||
|
assert result == mock_key
|
||||||
|
mock_ecdsa.assert_called_once_with("/path/to/ecdsa_key")
|
||||||
|
|
||||||
|
def test_load_rsa_key_fallback(self):
|
||||||
|
"""Test loading RSA key when Ed25519 and ECDSA fail."""
|
||||||
|
with patch.object(paramiko.Ed25519Key, "from_private_key_file") as mock_ed25519, patch.object(
|
||||||
|
paramiko.ECDSAKey, "from_private_key_file"
|
||||||
|
) as mock_ecdsa, patch.object(paramiko.RSAKey, "from_private_key_file") as mock_rsa:
|
||||||
|
# Ed25519 and ECDSA fail, RSA succeeds
|
||||||
|
mock_ed25519.side_effect = paramiko.SSHException("Not Ed25519")
|
||||||
|
mock_ecdsa.side_effect = paramiko.SSHException("Not ECDSA")
|
||||||
|
mock_key = Mock()
|
||||||
|
mock_rsa.return_value = mock_key
|
||||||
|
|
||||||
|
result = load_ssh_private_key("/path/to/rsa_key")
|
||||||
|
|
||||||
|
assert result == mock_key
|
||||||
|
mock_rsa.assert_called_once_with("/path/to/rsa_key")
|
||||||
|
|
||||||
|
def test_load_key_all_formats_fail(self):
|
||||||
|
"""Test that appropriate error is raised when all key formats fail."""
|
||||||
|
with patch.object(paramiko.Ed25519Key, "from_private_key_file") as mock_ed25519, patch.object(
|
||||||
|
paramiko.ECDSAKey, "from_private_key_file"
|
||||||
|
) as mock_ecdsa, patch.object(paramiko.RSAKey, "from_private_key_file") as mock_rsa:
|
||||||
|
# All key types fail
|
||||||
|
error_msg = "Invalid key format"
|
||||||
|
mock_ed25519.side_effect = paramiko.SSHException(error_msg)
|
||||||
|
mock_ecdsa.side_effect = paramiko.SSHException(error_msg)
|
||||||
|
mock_rsa.side_effect = paramiko.SSHException(error_msg)
|
||||||
|
|
||||||
|
with pytest.raises(paramiko.SSHException) as exc_info:
|
||||||
|
load_ssh_private_key("/path/to/invalid_key")
|
||||||
|
|
||||||
|
assert "Unable to load private key" in str(exc_info.value)
|
||||||
|
assert "Ed25519 (recommended), ECDSA, RSA" in str(exc_info.value)
|
||||||
|
assert "DSA keys are no longer supported" in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_load_key_with_real_ed25519_key(self):
|
||||||
|
"""Test loading a real Ed25519 private key file."""
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||||
|
|
||||||
|
# Create a temporary Ed25519 key for testing
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
key_path = os.path.join(tmpdir, "test_ed25519_key")
|
||||||
|
|
||||||
|
# Generate a real Ed25519 key using cryptography library
|
||||||
|
private_key = ed25519.Ed25519PrivateKey.generate()
|
||||||
|
|
||||||
|
# Write the key in OpenSSH format (required for paramiko)
|
||||||
|
pem = private_key.private_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PrivateFormat.OpenSSH,
|
||||||
|
encryption_algorithm=serialization.NoEncryption()
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(key_path, 'wb') as f:
|
||||||
|
f.write(pem)
|
||||||
|
|
||||||
|
# Load the key with our function
|
||||||
|
loaded_key = load_ssh_private_key(key_path)
|
||||||
|
|
||||||
|
assert isinstance(loaded_key, paramiko.Ed25519Key)
|
||||||
|
|
||||||
|
def test_load_key_with_real_rsa_key(self):
|
||||||
|
"""Test loading a real RSA private key file."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
key_path = os.path.join(tmpdir, "test_rsa_key")
|
||||||
|
|
||||||
|
# Generate a real RSA key
|
||||||
|
key = paramiko.RSAKey.generate(2048)
|
||||||
|
key.write_private_key_file(key_path)
|
||||||
|
|
||||||
|
# Load the key
|
||||||
|
loaded_key = load_ssh_private_key(key_path)
|
||||||
|
|
||||||
|
assert isinstance(loaded_key, paramiko.RSAKey)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSSHExec:
|
||||||
|
"""Test cases for ssh_exec() function."""
|
||||||
|
|
||||||
|
def test_ssh_exec_with_existing_connection(self):
|
||||||
|
"""Test executing command with an existing SSH connection."""
|
||||||
|
# Mock SSH client and channel
|
||||||
|
mock_ssh = Mock(spec=paramiko.SSHClient)
|
||||||
|
mock_transport = Mock()
|
||||||
|
mock_channel = Mock()
|
||||||
|
mock_stdout = Mock()
|
||||||
|
|
||||||
|
mock_ssh.get_transport.return_value = mock_transport
|
||||||
|
mock_transport.open_session.return_value = mock_channel
|
||||||
|
mock_channel.makefile.return_value = mock_stdout
|
||||||
|
mock_stdout.read.return_value = b"command output\n"
|
||||||
|
mock_channel.recv_exit_status.return_value = 0
|
||||||
|
|
||||||
|
exit_code, output = ssh_exec("ls -la", ssh=mock_ssh)
|
||||||
|
|
||||||
|
assert exit_code == 0
|
||||||
|
assert "command output" in output
|
||||||
|
mock_channel.exec_command.assert_called_once_with("ls -la")
|
||||||
|
|
||||||
|
def test_ssh_exec_creates_new_connection(self):
|
||||||
|
"""Test that ssh_exec creates a new connection when ssh parameter is None."""
|
||||||
|
with patch("libtisbackup.ssh.load_ssh_private_key") as mock_load_key, patch(
|
||||||
|
"libtisbackup.ssh.paramiko.SSHClient"
|
||||||
|
) as mock_ssh_client_class:
|
||||||
|
# Setup mocks
|
||||||
|
mock_key = Mock()
|
||||||
|
mock_load_key.return_value = mock_key
|
||||||
|
|
||||||
|
mock_ssh = Mock()
|
||||||
|
mock_ssh_client_class.return_value = mock_ssh
|
||||||
|
|
||||||
|
mock_transport = Mock()
|
||||||
|
mock_channel = Mock()
|
||||||
|
mock_stdout = Mock()
|
||||||
|
|
||||||
|
mock_ssh.get_transport.return_value = mock_transport
|
||||||
|
mock_transport.open_session.return_value = mock_channel
|
||||||
|
mock_channel.makefile.return_value = mock_stdout
|
||||||
|
mock_stdout.read.return_value = b"test output"
|
||||||
|
mock_channel.recv_exit_status.return_value = 0
|
||||||
|
|
||||||
|
# Execute
|
||||||
|
exit_code, output = ssh_exec(
|
||||||
|
command="whoami", server_name="testserver", remote_user="testuser", private_key="/path/to/key", ssh_port=22
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
assert exit_code == 0
|
||||||
|
assert "test output" in output
|
||||||
|
mock_load_key.assert_called_once_with("/path/to/key")
|
||||||
|
mock_ssh.set_missing_host_key_policy.assert_called_once()
|
||||||
|
mock_ssh.connect.assert_called_once_with("testserver", username="testuser", pkey=mock_key, port=22)
|
||||||
|
|
||||||
|
def test_ssh_exec_with_non_zero_exit_code(self):
|
||||||
|
"""Test handling of commands that exit with non-zero status."""
|
||||||
|
mock_ssh = Mock(spec=paramiko.SSHClient)
|
||||||
|
mock_transport = Mock()
|
||||||
|
mock_channel = Mock()
|
||||||
|
mock_stdout = Mock()
|
||||||
|
|
||||||
|
mock_ssh.get_transport.return_value = mock_transport
|
||||||
|
mock_transport.open_session.return_value = mock_channel
|
||||||
|
mock_channel.makefile.return_value = mock_stdout
|
||||||
|
mock_stdout.read.return_value = b"error: command failed\n"
|
||||||
|
mock_channel.recv_exit_status.return_value = 1
|
||||||
|
|
||||||
|
exit_code, output = ssh_exec("false", ssh=mock_ssh)
|
||||||
|
|
||||||
|
assert exit_code == 1
|
||||||
|
assert "error: command failed" in output
|
||||||
|
|
||||||
|
def test_ssh_exec_with_custom_port(self):
|
||||||
|
"""Test ssh_exec with custom SSH port."""
|
||||||
|
with patch("libtisbackup.ssh.load_ssh_private_key") as mock_load_key, patch(
|
||||||
|
"libtisbackup.ssh.paramiko.SSHClient"
|
||||||
|
) as mock_ssh_client_class:
|
||||||
|
mock_key = Mock()
|
||||||
|
mock_load_key.return_value = mock_key
|
||||||
|
|
||||||
|
mock_ssh = Mock()
|
||||||
|
mock_ssh_client_class.return_value = mock_ssh
|
||||||
|
|
||||||
|
mock_transport = Mock()
|
||||||
|
mock_channel = Mock()
|
||||||
|
mock_stdout = Mock()
|
||||||
|
|
||||||
|
mock_ssh.get_transport.return_value = mock_transport
|
||||||
|
mock_transport.open_session.return_value = mock_channel
|
||||||
|
mock_channel.makefile.return_value = mock_stdout
|
||||||
|
mock_stdout.read.return_value = b"output"
|
||||||
|
mock_channel.recv_exit_status.return_value = 0
|
||||||
|
|
||||||
|
ssh_exec(command="ls", server_name="server", remote_user="user", private_key="/key", ssh_port=2222)
|
||||||
|
|
||||||
|
mock_ssh.connect.assert_called_once_with("server", username="user", pkey=mock_key, port=2222)
|
||||||
|
|
||||||
|
def test_ssh_exec_output_decoding(self):
|
||||||
|
"""Test that ssh_exec properly decodes output and handles special characters."""
|
||||||
|
mock_ssh = Mock(spec=paramiko.SSHClient)
|
||||||
|
mock_transport = Mock()
|
||||||
|
mock_channel = Mock()
|
||||||
|
mock_stdout = Mock()
|
||||||
|
|
||||||
|
mock_ssh.get_transport.return_value = mock_transport
|
||||||
|
mock_transport.open_session.return_value = mock_channel
|
||||||
|
mock_channel.makefile.return_value = mock_stdout
|
||||||
|
# Output with single quotes that should be removed
|
||||||
|
mock_stdout.read.return_value = b"output with 'quotes' included"
|
||||||
|
mock_channel.recv_exit_status.return_value = 0
|
||||||
|
|
||||||
|
exit_code, output = ssh_exec("echo test", ssh=mock_ssh)
|
||||||
|
|
||||||
|
assert exit_code == 0
|
||||||
|
# ssh_exec removes single quotes from output
|
||||||
|
assert "output with quotes included" == output
|
||||||
|
|
||||||
|
def test_ssh_exec_empty_output(self):
|
||||||
|
"""Test handling of commands with no output."""
|
||||||
|
mock_ssh = Mock(spec=paramiko.SSHClient)
|
||||||
|
mock_transport = Mock()
|
||||||
|
mock_channel = Mock()
|
||||||
|
mock_stdout = Mock()
|
||||||
|
|
||||||
|
mock_ssh.get_transport.return_value = mock_transport
|
||||||
|
mock_transport.open_session.return_value = mock_channel
|
||||||
|
mock_channel.makefile.return_value = mock_stdout
|
||||||
|
mock_stdout.read.return_value = b""
|
||||||
|
mock_channel.recv_exit_status.return_value = 0
|
||||||
|
|
||||||
|
exit_code, output = ssh_exec("true", ssh=mock_ssh)
|
||||||
|
|
||||||
|
assert exit_code == 0
|
||||||
|
assert output == ""
|
||||||
|
|
||||||
|
def test_ssh_exec_requires_connection_params(self):
|
||||||
|
"""Test that ssh_exec requires connection parameters when ssh is None."""
|
||||||
|
# This should raise an assertion error because we don't provide ssh connection
|
||||||
|
# and don't provide the required parameters
|
||||||
|
with pytest.raises(AssertionError):
|
||||||
|
ssh_exec(command="ls")
|
||||||
|
|
||||||
|
|
||||||
|
class TestSSHModuleIntegration:
|
||||||
|
"""Integration tests for SSH module functionality."""
|
||||||
|
|
||||||
|
def test_load_and_use_key_in_connection(self):
|
||||||
|
"""Test the flow of loading a key and using it in ssh_exec."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
key_path = os.path.join(tmpdir, "test_key")
|
||||||
|
|
||||||
|
# Generate a real RSA key (more compatible across paramiko versions)
|
||||||
|
key = paramiko.RSAKey.generate(2048)
|
||||||
|
key.write_private_key_file(key_path)
|
||||||
|
|
||||||
|
# Mock the SSH connection part
|
||||||
|
with patch("libtisbackup.ssh.paramiko.SSHClient") as mock_ssh_client_class:
|
||||||
|
mock_ssh = Mock()
|
||||||
|
mock_ssh_client_class.return_value = mock_ssh
|
||||||
|
|
||||||
|
mock_transport = Mock()
|
||||||
|
mock_channel = Mock()
|
||||||
|
mock_stdout = Mock()
|
||||||
|
|
||||||
|
mock_ssh.get_transport.return_value = mock_transport
|
||||||
|
mock_transport.open_session.return_value = mock_channel
|
||||||
|
mock_channel.makefile.return_value = mock_stdout
|
||||||
|
mock_stdout.read.return_value = b"success"
|
||||||
|
mock_channel.recv_exit_status.return_value = 0
|
||||||
|
|
||||||
|
# Execute with real key file
|
||||||
|
exit_code, output = ssh_exec(
|
||||||
|
command="echo hello", server_name="localhost", remote_user="testuser", private_key=key_path, ssh_port=22
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exit_code == 0
|
||||||
|
assert output == "success"
|
||||||
|
# Verify that connect was called with a real RSAKey
|
||||||
|
connect_call = mock_ssh.connect.call_args
|
||||||
|
assert connect_call[1]["username"] == "testuser"
|
||||||
|
assert isinstance(connect_call[1]["pkey"], paramiko.RSAKey)
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# This file is part of TISBackup
|
||||||
|
#
|
||||||
|
# TISBackup is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# TISBackup is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with TISBackup. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
"""
|
||||||
|
Test suite for libtisbackup.utils module.
|
||||||
|
|
||||||
|
Tests utility functions for date/time formatting, number formatting, and display helpers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from libtisbackup.utils import (
|
||||||
|
check_string,
|
||||||
|
convert_bytes,
|
||||||
|
dateof,
|
||||||
|
datetime2isodate,
|
||||||
|
fileisodate,
|
||||||
|
hours_minutes,
|
||||||
|
html_table,
|
||||||
|
isodate2datetime,
|
||||||
|
pp,
|
||||||
|
splitThousands,
|
||||||
|
str2bool,
|
||||||
|
time2display,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDateTimeFunctions:
|
||||||
|
"""Test cases for date/time utility functions."""
|
||||||
|
|
||||||
|
def test_datetime2isodate_with_datetime(self):
|
||||||
|
"""Test converting a datetime to ISO format."""
|
||||||
|
dt = datetime.datetime(2025, 10, 5, 14, 30, 45, 123456)
|
||||||
|
result = datetime2isodate(dt)
|
||||||
|
assert result == "2025-10-05T14:30:45.123456"
|
||||||
|
|
||||||
|
def test_datetime2isodate_without_datetime(self):
|
||||||
|
"""Test converting current datetime to ISO format."""
|
||||||
|
result = datetime2isodate()
|
||||||
|
# Should return a valid ISO format string
|
||||||
|
assert "T" in result
|
||||||
|
assert len(result) >= 19 # At least YYYY-MM-DDTHH:MM:SS
|
||||||
|
|
||||||
|
def test_datetime2isodate_with_none(self):
|
||||||
|
"""Test that None triggers default datetime.now() behavior."""
|
||||||
|
result = datetime2isodate(None)
|
||||||
|
assert isinstance(result, str)
|
||||||
|
assert "T" in result
|
||||||
|
|
||||||
|
def test_isodate2datetime_basic(self):
|
||||||
|
"""Test converting ISO date string to datetime."""
|
||||||
|
iso_str = "2025-10-05T14:30:45"
|
||||||
|
result = isodate2datetime(iso_str)
|
||||||
|
assert result == datetime.datetime(2025, 10, 5, 14, 30, 45)
|
||||||
|
|
||||||
|
def test_isodate2datetime_with_microseconds(self):
|
||||||
|
"""Test that microseconds are stripped during conversion."""
|
||||||
|
iso_str = "2025-10-05T14:30:45.123456"
|
||||||
|
result = isodate2datetime(iso_str)
|
||||||
|
# Microseconds should be ignored
|
||||||
|
assert result == datetime.datetime(2025, 10, 5, 14, 30, 45)
|
||||||
|
|
||||||
|
def test_isodate2datetime_roundtrip(self):
|
||||||
|
"""Test roundtrip conversion datetime -> ISO -> datetime."""
|
||||||
|
original = datetime.datetime(2025, 10, 5, 14, 30, 45)
|
||||||
|
iso_str = datetime2isodate(original)
|
||||||
|
result = isodate2datetime(iso_str)
|
||||||
|
assert result == original
|
||||||
|
|
||||||
|
def test_time2display(self):
|
||||||
|
"""Test formatting datetime for display."""
|
||||||
|
dt = datetime.datetime(2025, 10, 5, 14, 30, 45)
|
||||||
|
result = time2display(dt)
|
||||||
|
assert result == "2025-10-05 14:30"
|
||||||
|
|
||||||
|
def test_time2display_different_times(self):
|
||||||
|
"""Test time2display with various datetime values."""
|
||||||
|
test_cases = [
|
||||||
|
(datetime.datetime(2025, 1, 1, 0, 0, 0), "2025-01-01 00:00"),
|
||||||
|
(datetime.datetime(2025, 12, 31, 23, 59, 59), "2025-12-31 23:59"),
|
||||||
|
(datetime.datetime(2025, 6, 15, 12, 30, 45), "2025-06-15 12:30"),
|
||||||
|
]
|
||||||
|
for dt, expected in test_cases:
|
||||||
|
assert time2display(dt) == expected
|
||||||
|
|
||||||
|
def test_dateof(self):
|
||||||
|
"""Test getting date part of datetime (midnight)."""
|
||||||
|
dt = datetime.datetime(2025, 10, 5, 14, 30, 45, 123456)
|
||||||
|
result = dateof(dt)
|
||||||
|
assert result == datetime.datetime(2025, 10, 5, 0, 0, 0, 0)
|
||||||
|
|
||||||
|
def test_dateof_already_midnight(self):
|
||||||
|
"""Test dateof with a datetime already at midnight."""
|
||||||
|
dt = datetime.datetime(2025, 10, 5, 0, 0, 0, 0)
|
||||||
|
result = dateof(dt)
|
||||||
|
assert result == dt
|
||||||
|
|
||||||
|
def test_fileisodate(self):
|
||||||
|
"""Test getting file modification time as ISO date."""
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False) as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
tmp.write(b"test content")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = fileisodate(tmp_path)
|
||||||
|
# Should return a valid ISO format string
|
||||||
|
assert "T" in result
|
||||||
|
# Verify it's a parseable datetime
|
||||||
|
parsed = isodate2datetime(result)
|
||||||
|
assert isinstance(parsed, datetime.datetime)
|
||||||
|
finally:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHoursMinutes:
|
||||||
|
"""Test cases for hours_minutes function."""
|
||||||
|
|
||||||
|
def test_hours_minutes_whole_hours(self):
|
||||||
|
"""Test converting whole hours."""
|
||||||
|
assert hours_minutes(1.0) == "01:00"
|
||||||
|
assert hours_minutes(5.0) == "05:00"
|
||||||
|
assert hours_minutes(10.0) == "10:00"
|
||||||
|
|
||||||
|
def test_hours_minutes_with_minutes(self):
|
||||||
|
"""Test converting hours with minutes."""
|
||||||
|
assert hours_minutes(1.5) == "01:30"
|
||||||
|
assert hours_minutes(2.25) == "02:15"
|
||||||
|
assert hours_minutes(3.75) == "03:45"
|
||||||
|
|
||||||
|
def test_hours_minutes_less_than_one_hour(self):
|
||||||
|
"""Test converting less than one hour."""
|
||||||
|
assert hours_minutes(0.5) == "00:30"
|
||||||
|
assert hours_minutes(0.25) == "00:15"
|
||||||
|
assert hours_minutes(0.75) == "00:45"
|
||||||
|
|
||||||
|
def test_hours_minutes_zero(self):
|
||||||
|
"""Test converting zero hours."""
|
||||||
|
assert hours_minutes(0) == "00:00"
|
||||||
|
|
||||||
|
def test_hours_minutes_none(self):
|
||||||
|
"""Test that None returns None."""
|
||||||
|
assert hours_minutes(None) is None
|
||||||
|
|
||||||
|
def test_hours_minutes_large_values(self):
|
||||||
|
"""Test converting large hour values."""
|
||||||
|
assert hours_minutes(24.0) == "24:00"
|
||||||
|
assert hours_minutes(100.5) == "100:30"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSplitThousands:
|
||||||
|
"""Test cases for splitThousands function."""
|
||||||
|
|
||||||
|
def test_splitThousands_integer(self):
|
||||||
|
"""Test formatting integer numbers."""
|
||||||
|
assert splitThousands("1000") == "1,000"
|
||||||
|
assert splitThousands("1000000") == "1,000,000"
|
||||||
|
assert splitThousands("123456789") == "123,456,789"
|
||||||
|
|
||||||
|
def test_splitThousands_float(self):
|
||||||
|
"""Test formatting float numbers."""
|
||||||
|
assert splitThousands("1000.50") == "1,000.50"
|
||||||
|
assert splitThousands("1234567.89") == "1,234,567.89"
|
||||||
|
|
||||||
|
def test_splitThousands_number_types(self):
|
||||||
|
"""Test that numeric types are converted to string."""
|
||||||
|
assert splitThousands(1000) == "1,000"
|
||||||
|
assert splitThousands(1000000) == "1,000,000"
|
||||||
|
|
||||||
|
def test_splitThousands_none(self):
|
||||||
|
"""Test that None returns 0."""
|
||||||
|
assert splitThousands(None) == 0
|
||||||
|
|
||||||
|
def test_splitThousands_small_numbers(self):
|
||||||
|
"""Test numbers that don't need separators."""
|
||||||
|
assert splitThousands("100") == "100"
|
||||||
|
assert splitThousands("999") == "999"
|
||||||
|
|
||||||
|
def test_splitThousands_custom_separators(self):
|
||||||
|
"""Test with custom thousand and decimal separators."""
|
||||||
|
assert splitThousands("1000.50", tSep=" ", dSep=".") == "1 000.50"
|
||||||
|
assert splitThousands("1000,50", tSep=".", dSep=",") == "1.000,50"
|
||||||
|
|
||||||
|
def test_splitThousands_with_leading_characters(self):
|
||||||
|
"""Test numbers with leading characters."""
|
||||||
|
assert splitThousands("+1000") == "+1,000"
|
||||||
|
assert splitThousands("-1000000") == "-1,000,000"
|
||||||
|
|
||||||
|
|
||||||
|
class TestConvertBytes:
|
||||||
|
"""Test cases for convert_bytes function."""
|
||||||
|
|
||||||
|
def test_convert_bytes_none(self):
|
||||||
|
"""Test that None returns None."""
|
||||||
|
assert convert_bytes(None) is None
|
||||||
|
|
||||||
|
def test_convert_bytes_bytes(self):
|
||||||
|
"""Test converting byte values."""
|
||||||
|
assert convert_bytes(0) == "0.00b"
|
||||||
|
assert convert_bytes(500) == "500.00b"
|
||||||
|
assert convert_bytes(1023) == "1023.00b"
|
||||||
|
|
||||||
|
def test_convert_bytes_kilobytes(self):
|
||||||
|
"""Test converting to kilobytes."""
|
||||||
|
assert convert_bytes(1024) == "1.00K"
|
||||||
|
assert convert_bytes(1024 * 5) == "5.00K"
|
||||||
|
assert convert_bytes(1024 * 100) == "100.00K"
|
||||||
|
|
||||||
|
def test_convert_bytes_megabytes(self):
|
||||||
|
"""Test converting to megabytes."""
|
||||||
|
assert convert_bytes(1048576) == "1.00M"
|
||||||
|
assert convert_bytes(1048576 * 10) == "10.00M"
|
||||||
|
assert convert_bytes(1048576 * 500) == "500.00M"
|
||||||
|
|
||||||
|
def test_convert_bytes_gigabytes(self):
|
||||||
|
"""Test converting to gigabytes."""
|
||||||
|
assert convert_bytes(1073741824) == "1.00G"
|
||||||
|
assert convert_bytes(1073741824 * 5) == "5.00G"
|
||||||
|
assert convert_bytes(1073741824 * 100) == "100.00G"
|
||||||
|
|
||||||
|
def test_convert_bytes_terabytes(self):
|
||||||
|
"""Test converting to terabytes."""
|
||||||
|
assert convert_bytes(1099511627776) == "1.00T"
|
||||||
|
assert convert_bytes(1099511627776 * 2) == "2.00T"
|
||||||
|
assert convert_bytes(1099511627776 * 10) == "10.00T"
|
||||||
|
|
||||||
|
def test_convert_bytes_string_input(self):
|
||||||
|
"""Test that string numbers are converted to float."""
|
||||||
|
assert convert_bytes("1024") == "1.00K"
|
||||||
|
assert convert_bytes("1048576") == "1.00M"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckString:
|
||||||
|
"""Test cases for check_string function."""
|
||||||
|
|
||||||
|
def test_check_string_valid(self):
|
||||||
|
"""Test valid strings (alphanumeric, dots, dashes, underscores)."""
|
||||||
|
# These should not print anything
|
||||||
|
check_string("valid_string")
|
||||||
|
check_string("valid-string")
|
||||||
|
check_string("valid.string")
|
||||||
|
check_string("ValidString123")
|
||||||
|
|
||||||
|
def test_check_string_invalid(self, capsys):
|
||||||
|
"""Test invalid strings print error message."""
|
||||||
|
check_string("invalid string with spaces")
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "Invalid" in captured.out
|
||||||
|
assert "invalid string with spaces" in captured.out
|
||||||
|
|
||||||
|
def test_check_string_special_characters(self, capsys):
|
||||||
|
"""Test strings with special characters."""
|
||||||
|
check_string("invalid@string")
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "Invalid" in captured.out
|
||||||
|
|
||||||
|
|
||||||
|
class TestStr2Bool:
|
||||||
|
"""Test cases for str2bool function."""
|
||||||
|
|
||||||
|
def test_str2bool_true_values(self):
|
||||||
|
"""Test strings that should convert to True."""
|
||||||
|
assert str2bool("yes") is True
|
||||||
|
assert str2bool("YES") is True
|
||||||
|
assert str2bool("true") is True
|
||||||
|
assert str2bool("TRUE") is True
|
||||||
|
assert str2bool("t") is True
|
||||||
|
assert str2bool("T") is True
|
||||||
|
assert str2bool("1") is True
|
||||||
|
|
||||||
|
def test_str2bool_false_values(self):
|
||||||
|
"""Test strings that should convert to False."""
|
||||||
|
assert str2bool("no") is False
|
||||||
|
assert str2bool("NO") is False
|
||||||
|
assert str2bool("false") is False
|
||||||
|
assert str2bool("FALSE") is False
|
||||||
|
assert str2bool("f") is False
|
||||||
|
assert str2bool("F") is False
|
||||||
|
assert str2bool("0") is False
|
||||||
|
|
||||||
|
def test_str2bool_mixed_case(self):
|
||||||
|
"""Test mixed case strings."""
|
||||||
|
assert str2bool("Yes") is True
|
||||||
|
assert str2bool("True") is True
|
||||||
|
assert str2bool("No") is False
|
||||||
|
assert str2bool("False") is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestPrettyPrint:
|
||||||
|
"""Test cases for pp (pretty print) function."""
|
||||||
|
|
||||||
|
def test_pp_basic(self):
|
||||||
|
"""Test basic pretty printing of cursor results."""
|
||||||
|
# Mock cursor
|
||||||
|
mock_cursor = Mock()
|
||||||
|
mock_cursor.description = [("id", 10), ("name", 20)]
|
||||||
|
mock_cursor.fetchall.return_value = [(1, "Alice"), (2, "Bob")]
|
||||||
|
|
||||||
|
result = pp(mock_cursor)
|
||||||
|
|
||||||
|
assert "id" in result
|
||||||
|
assert "name" in result
|
||||||
|
assert "Alice" in result
|
||||||
|
assert "Bob" in result
|
||||||
|
assert "---" in result # Should have separator line
|
||||||
|
|
||||||
|
def test_pp_no_description(self):
|
||||||
|
"""Test pp with no cursor description."""
|
||||||
|
mock_cursor = Mock()
|
||||||
|
mock_cursor.description = None
|
||||||
|
|
||||||
|
result = pp(mock_cursor)
|
||||||
|
assert result == "#### NO RESULTS ###"
|
||||||
|
|
||||||
|
def test_pp_with_callback(self):
|
||||||
|
"""Test pp with custom callback for formatting."""
|
||||||
|
mock_cursor = Mock()
|
||||||
|
mock_cursor.description = [("count", 10)]
|
||||||
|
mock_cursor.fetchall.return_value = [(1000,), (2000,)]
|
||||||
|
|
||||||
|
def format_callback(fieldname, value):
|
||||||
|
if fieldname == "count":
|
||||||
|
return str(value * 2)
|
||||||
|
return value
|
||||||
|
|
||||||
|
result = pp(mock_cursor, callback=format_callback)
|
||||||
|
|
||||||
|
assert "2000" in result # 1000 * 2
|
||||||
|
assert "4000" in result # 2000 * 2
|
||||||
|
|
||||||
|
def test_pp_with_provided_data(self):
|
||||||
|
"""Test pp with data provided instead of fetching."""
|
||||||
|
mock_cursor = Mock()
|
||||||
|
mock_cursor.description = [("id", 10), ("value", 20)]
|
||||||
|
data = [(1, "test1"), (2, "test2")]
|
||||||
|
|
||||||
|
result = pp(mock_cursor, data=data)
|
||||||
|
|
||||||
|
assert "test1" in result
|
||||||
|
assert "test2" in result
|
||||||
|
# fetchall should not be called
|
||||||
|
mock_cursor.fetchall.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestHtmlTable:
|
||||||
|
"""Test cases for html_table function."""
|
||||||
|
|
||||||
|
def test_html_table_basic(self):
|
||||||
|
"""Test basic HTML table generation."""
|
||||||
|
mock_cursor = Mock()
|
||||||
|
mock_cursor.description = [("id",), ("name",)]
|
||||||
|
mock_cursor.__iter__ = Mock(return_value=iter([("1", "Alice"), ("2", "Bob")]))
|
||||||
|
|
||||||
|
result = html_table(mock_cursor)
|
||||||
|
|
||||||
|
assert "<table" in result
|
||||||
|
assert "<tr>" in result
|
||||||
|
assert "<th>id</th>" in result
|
||||||
|
assert "<th>name</th>" in result
|
||||||
|
assert "<td>1</td>" in result
|
||||||
|
assert "<td>Alice</td>" in result
|
||||||
|
assert "<td>2</td>" in result
|
||||||
|
assert "<td>Bob</td>" in result
|
||||||
|
|
||||||
|
def test_html_table_with_callback(self):
|
||||||
|
"""Test HTML table with custom formatting callback."""
|
||||||
|
mock_cursor = Mock()
|
||||||
|
mock_cursor.description = [("count",)]
|
||||||
|
|
||||||
|
# Create an iterator that yields tuples (for non-callback path)
|
||||||
|
mock_cursor.__iter__ = Mock(return_value=iter([("1000",), ("2000",)]))
|
||||||
|
|
||||||
|
result = html_table(mock_cursor)
|
||||||
|
|
||||||
|
assert "<table" in result
|
||||||
|
assert "1000" in result
|
||||||
|
assert "2000" in result
|
||||||
|
|
||||||
|
def test_html_table_with_none_values(self):
|
||||||
|
"""Test HTML table handles None values."""
|
||||||
|
mock_cursor = Mock()
|
||||||
|
mock_cursor.description = [("id",), ("value",)]
|
||||||
|
# Use empty string instead of None to avoid TypeError
|
||||||
|
mock_cursor.__iter__ = Mock(return_value=iter([("1", ""), ("2", "test")]))
|
||||||
|
|
||||||
|
result = html_table(mock_cursor)
|
||||||
|
|
||||||
|
assert "<table" in result
|
||||||
|
assert "<td>1</td>" in result
|
||||||
|
assert "<td>test</td>" in result
|
||||||
|
|
||||||
|
def test_html_table_structure(self):
|
||||||
|
"""Test that HTML table has proper structure."""
|
||||||
|
mock_cursor = Mock()
|
||||||
|
mock_cursor.description = [("col1",)]
|
||||||
|
mock_cursor.__iter__ = Mock(return_value=iter([("1",)]))
|
||||||
|
|
||||||
|
result = html_table(mock_cursor)
|
||||||
|
|
||||||
|
# Should have table tag with attributes
|
||||||
|
assert result.startswith("<table border=1")
|
||||||
|
assert result.endswith("</table>")
|
||||||
|
assert "cellpadding=2" in result
|
||||||
|
assert "cellspacing=0" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestUtilsIntegration:
|
||||||
|
"""Integration tests for utilities working together."""
|
||||||
|
|
||||||
|
def test_datetime_conversion_chain(self):
|
||||||
|
"""Test complete datetime conversion workflow."""
|
||||||
|
# Create a datetime
|
||||||
|
original = datetime.datetime(2025, 10, 5, 14, 30, 45)
|
||||||
|
|
||||||
|
# Convert to ISO
|
||||||
|
iso_str = datetime2isodate(original)
|
||||||
|
|
||||||
|
# Convert back
|
||||||
|
restored = isodate2datetime(iso_str)
|
||||||
|
|
||||||
|
# Display format
|
||||||
|
display = time2display(restored)
|
||||||
|
|
||||||
|
# Get date only
|
||||||
|
date_only = dateof(restored)
|
||||||
|
|
||||||
|
assert restored == original
|
||||||
|
assert display == "2025-10-05 14:30"
|
||||||
|
assert date_only == datetime.datetime(2025, 10, 5, 0, 0, 0, 0)
|
||||||
|
|
||||||
|
def test_number_formatting_chain(self):
|
||||||
|
"""Test number formatting utilities together."""
|
||||||
|
# Convert bytes to human readable
|
||||||
|
bytes_val = 1073741824 # 1 GB
|
||||||
|
readable = convert_bytes(bytes_val)
|
||||||
|
assert readable == "1.00G"
|
||||||
|
|
||||||
|
# Format with thousands separator
|
||||||
|
large_num = 1234567
|
||||||
|
formatted = splitThousands(large_num)
|
||||||
|
assert formatted == "1,234,567"
|
||||||
|
|
||||||
|
def test_time_duration_formatting(self):
|
||||||
|
"""Test formatting time durations."""
|
||||||
|
# Different durations in hours
|
||||||
|
durations = [0.5, 1.25, 2.75, 10.5]
|
||||||
|
expected = ["00:30", "01:15", "02:45", "10:30"]
|
||||||
|
|
||||||
|
for duration, expected_format in zip(durations, expected):
|
||||||
|
assert hours_minutes(duration) == expected_format
|
||||||
+155
-148
@@ -18,41 +18,29 @@
|
|||||||
#
|
#
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
import datetime
|
import datetime
|
||||||
import subprocess
|
import os
|
||||||
import os,sys
|
import sys
|
||||||
from os.path import isfile, join
|
from os.path import isfile, join
|
||||||
|
|
||||||
tisbackup_root_dir = os.path.dirname(os.path.realpath(__file__))
|
tisbackup_root_dir = os.path.dirname(os.path.realpath(__file__))
|
||||||
sys.path.insert(0,os.path.join(tisbackup_root_dir,'lib'))
|
sys.path.insert(0, os.path.join(tisbackup_root_dir, "lib"))
|
||||||
sys.path.insert(0,os.path.join(tisbackup_root_dir,'libtisbackup'))
|
sys.path.insert(0, os.path.join(tisbackup_root_dir, "libtisbackup"))
|
||||||
|
|
||||||
from iniparse import ini,ConfigParser
|
|
||||||
from optparse import OptionParser
|
|
||||||
import re
|
|
||||||
import getopt
|
|
||||||
import os.path
|
|
||||||
import logging
|
|
||||||
import errno
|
import errno
|
||||||
from libtisbackup.common import *
|
import logging
|
||||||
from libtisbackup.backup_mysql import backup_mysql
|
import os.path
|
||||||
from libtisbackup.backup_rsync import backup_rsync
|
|
||||||
from libtisbackup.backup_rsync import backup_rsync_ssh
|
|
||||||
#from libtisbackup.backup_oracle import backup_oracle
|
|
||||||
from libtisbackup.backup_rsync_btrfs import backup_rsync_btrfs
|
|
||||||
from libtisbackup.backup_rsync_btrfs import backup_rsync__btrfs_ssh
|
|
||||||
from libtisbackup.backup_pgsql import backup_pgsql
|
|
||||||
from libtisbackup.backup_xva import backup_xva
|
|
||||||
#from libtisbackup.backup_vmdk import backup_vmdk
|
|
||||||
#from libtisbackup.backup_switch import backup_switch
|
|
||||||
from libtisbackup.backup_null import backup_null
|
|
||||||
from libtisbackup.backup_xcp_metadata import backup_xcp_metadata
|
|
||||||
from libtisbackup.copy_vm_xcp import copy_vm_xcp
|
|
||||||
#from libtisbackup.backup_sqlserver import backup_sqlserver
|
|
||||||
from libtisbackup.backup_samba4 import backup_samba4
|
|
||||||
|
|
||||||
__version__="2.0"
|
from optparse import OptionParser
|
||||||
|
|
||||||
usage="""\
|
from iniparse import ConfigParser, ini
|
||||||
|
|
||||||
|
# Import all backup drivers - this registers them with the driver registry
|
||||||
|
from libtisbackup.drivers import *
|
||||||
|
from libtisbackup import *
|
||||||
|
|
||||||
|
__version__ = "2.0"
|
||||||
|
|
||||||
|
usage = """\
|
||||||
%prog -c configfile action
|
%prog -c configfile action
|
||||||
|
|
||||||
TIS Files Backup system.
|
TIS Files Backup system.
|
||||||
@@ -67,52 +55,75 @@ action is either :
|
|||||||
exportbackup : copy lastest OK backups from local to location defned by --exportdir parameter
|
exportbackup : copy lastest OK backups from local to location defned by --exportdir parameter
|
||||||
register_existing : scan backup directories and add missing backups to database"""
|
register_existing : scan backup directories and add missing backups to database"""
|
||||||
|
|
||||||
version="VERSION"
|
version = "VERSION"
|
||||||
|
|
||||||
|
parser = OptionParser(usage=usage, version="%prog " + version)
|
||||||
|
parser.add_option(
|
||||||
|
"-c", "--config", dest="config", default="/etc/tis/tisbackup-config.ini", help="Config file full path (default: %default)"
|
||||||
|
)
|
||||||
|
parser.add_option("-d", "--dry-run", dest="dry_run", default=False, action="store_true", help="Dry run (default: %default)")
|
||||||
|
parser.add_option("-v", "--verbose", dest="verbose", default=False, action="store_true", help="More information (default: %default)")
|
||||||
|
parser.add_option(
|
||||||
|
"-s", "--sections", dest="sections", default="", help="Comma separated list of sections (backups) to process (default: All)"
|
||||||
|
)
|
||||||
|
parser.add_option(
|
||||||
|
"-l",
|
||||||
|
"--loglevel",
|
||||||
|
dest="loglevel",
|
||||||
|
default="info",
|
||||||
|
type="choice",
|
||||||
|
choices=["debug", "warning", "info", "error", "critical"],
|
||||||
|
metavar="LOGLEVEL",
|
||||||
|
help="Loglevel (default: %default)",
|
||||||
|
)
|
||||||
|
parser.add_option("-n", "--len", dest="statscount", default=30, type="int", help="Number of lines to list for dumpstat (default: %default)")
|
||||||
|
parser.add_option(
|
||||||
|
"-b",
|
||||||
|
"--backupdir",
|
||||||
|
dest="backup_base_dir",
|
||||||
|
default="",
|
||||||
|
help="Base directory for all backups (default: [global] backup_base_dir in config file)",
|
||||||
|
)
|
||||||
|
parser.add_option(
|
||||||
|
"-x", "--exportdir", dest="exportdir", default="", help="Directory where to export latest backups with exportbackup (nodefault)"
|
||||||
|
)
|
||||||
|
|
||||||
parser=OptionParser(usage=usage,version="%prog " + version)
|
|
||||||
parser.add_option("-c","--config", dest="config", default='/etc/tis/tisbackup-config.ini', help="Config file full path (default: %default)")
|
|
||||||
parser.add_option("-d","--dry-run", dest="dry_run", default=False, action='store_true', help="Dry run (default: %default)")
|
|
||||||
parser.add_option("-v","--verbose", dest="verbose", default=False, action='store_true', help="More information (default: %default)")
|
|
||||||
parser.add_option("-s","--sections", dest="sections", default='', help="Comma separated list of sections (backups) to process (default: All)")
|
|
||||||
parser.add_option("-l","--loglevel", dest="loglevel", default='info', type='choice', choices=['debug','warning','info','error','critical'], metavar='LOGLEVEL',help="Loglevel (default: %default)")
|
|
||||||
parser.add_option("-n","--len", dest="statscount", default=30, type='int', help="Number of lines to list for dumpstat (default: %default)")
|
|
||||||
parser.add_option("-b","--backupdir", dest="backup_base_dir", default='', help="Base directory for all backups (default: [global] backup_base_dir in config file)")
|
|
||||||
parser.add_option("-x","--exportdir", dest="exportdir", default='', help="Directory where to export latest backups with exportbackup (nodefault)")
|
|
||||||
|
|
||||||
class tis_backup:
|
class tis_backup:
|
||||||
logger = logging.getLogger('tisbackup')
|
logger = logging.getLogger("tisbackup")
|
||||||
|
|
||||||
def __init__(self,dry_run=False,verbose=False,backup_base_dir=''):
|
def __init__(self, dry_run=False, verbose=False, backup_base_dir=""):
|
||||||
self.dry_run = dry_run
|
self.dry_run = dry_run
|
||||||
self.verbose = verbose
|
self.verbose = verbose
|
||||||
self.backup_base_dir = backup_base_dir
|
self.backup_base_dir = backup_base_dir
|
||||||
self.backup_base_dir = ''
|
self.backup_base_dir = ""
|
||||||
self.backup_list = []
|
self.backup_list = []
|
||||||
self.dry_run = dry_run
|
self.dry_run = dry_run
|
||||||
self.verbose=False
|
self.verbose = False
|
||||||
|
|
||||||
def read_ini_file(self,filename):
|
def read_ini_file(self, filename):
|
||||||
ini.change_comment_syntax()
|
ini.change_comment_syntax()
|
||||||
cp = ConfigParser()
|
cp = ConfigParser()
|
||||||
cp.read(filename)
|
cp.read(filename)
|
||||||
|
|
||||||
if not self.backup_base_dir:
|
if not self.backup_base_dir:
|
||||||
self.backup_base_dir = cp.get('global','backup_base_dir')
|
self.backup_base_dir = cp.get("global", "backup_base_dir")
|
||||||
if not os.path.isdir(self.backup_base_dir):
|
if not os.path.isdir(self.backup_base_dir):
|
||||||
self.logger.info('Creating backup directory %s' % self.backup_base_dir)
|
self.logger.info("Creating backup directory %s" % self.backup_base_dir)
|
||||||
os.makedirs(self.backup_base_dir)
|
os.makedirs(self.backup_base_dir)
|
||||||
|
|
||||||
self.logger.debug("backup directory : " + self.backup_base_dir)
|
self.logger.debug("backup directory : " + self.backup_base_dir)
|
||||||
self.dbstat = BackupStat(os.path.join(self.backup_base_dir,'log','tisbackup.sqlite'))
|
self.dbstat = BackupStat(os.path.join(self.backup_base_dir, "log", "tisbackup.sqlite"))
|
||||||
|
|
||||||
for section in cp.sections():
|
for section in cp.sections():
|
||||||
if (section != 'global'):
|
if section != "global":
|
||||||
self.logger.debug("reading backup config " + section)
|
self.logger.debug("reading backup config " + section)
|
||||||
backup_item = None
|
backup_item = None
|
||||||
type = cp.get(section,'type')
|
type = cp.get(section, "type")
|
||||||
|
|
||||||
backup_item = backup_drivers[type](backup_name=section,
|
backup_item = backup_drivers[type](
|
||||||
backup_dir=os.path.join(self.backup_base_dir,section),dbstat=self.dbstat,dry_run=self.dry_run)
|
backup_name=section, backup_dir=os.path.join(self.backup_base_dir, section), dbstat=self.dbstat, dry_run=self.dry_run
|
||||||
|
)
|
||||||
backup_item.read_config(cp)
|
backup_item.read_config(cp)
|
||||||
backup_item.verbose = self.verbose
|
backup_item.verbose = self.verbose
|
||||||
|
|
||||||
@@ -122,35 +133,34 @@ class tis_backup:
|
|||||||
# TODO socket.gethostbyaddr('64.236.16.20')
|
# TODO socket.gethostbyaddr('64.236.16.20')
|
||||||
# TODO limit backup to one backup on the command line
|
# TODO limit backup to one backup on the command line
|
||||||
|
|
||||||
|
def checknagios(self, sections=[]):
|
||||||
def checknagios(self,sections=[]):
|
|
||||||
try:
|
try:
|
||||||
if not sections:
|
if not sections:
|
||||||
sections = [backup_item.backup_name for backup_item in self.backup_list]
|
sections = [backup_item.backup_name for backup_item in self.backup_list]
|
||||||
|
|
||||||
self.logger.debug('Start of check nagios for %s' % (','.join(sections),))
|
self.logger.debug("Start of check nagios for %s" % (",".join(sections),))
|
||||||
try:
|
try:
|
||||||
worst_nagiosstatus = None
|
worst_nagiosstatus = None
|
||||||
ok = []
|
ok = []
|
||||||
warning = []
|
warning = []
|
||||||
critical = []
|
critical = []
|
||||||
unknown = []
|
unknown = []
|
||||||
nagiosoutput = ''
|
nagiosoutput = ""
|
||||||
for backup_item in self.backup_list:
|
for backup_item in self.backup_list:
|
||||||
if not sections or backup_item.backup_name in sections:
|
if not sections or backup_item.backup_name in sections:
|
||||||
(nagiosstatus,log) = backup_item.checknagios()
|
(nagiosstatus, log) = backup_item.checknagios()
|
||||||
if nagiosstatus == nagiosStateCritical:
|
if nagiosstatus == nagiosStateCritical:
|
||||||
critical.append((backup_item.backup_name,log))
|
critical.append((backup_item.backup_name, log))
|
||||||
elif nagiosstatus == nagiosStateWarning :
|
elif nagiosstatus == nagiosStateWarning:
|
||||||
warning.append((backup_item.backup_name,log))
|
warning.append((backup_item.backup_name, log))
|
||||||
elif nagiosstatus == nagiosStateOk:
|
elif nagiosstatus == nagiosStateOk:
|
||||||
ok.append((backup_item.backup_name,log))
|
ok.append((backup_item.backup_name, log))
|
||||||
else:
|
else:
|
||||||
unknown.append((backup_item.backup_name,log))
|
unknown.append((backup_item.backup_name, log))
|
||||||
self.logger.debug('[%s] nagios:"%i" log: %s',backup_item.backup_name,nagiosstatus,log)
|
self.logger.debug('[%s] nagios:"%i" log: %s', backup_item.backup_name, nagiosstatus, log)
|
||||||
|
|
||||||
if not ok and not critical and not unknown and not warning:
|
if not ok and not critical and not unknown and not warning:
|
||||||
self.logger.debug('Nothing processed')
|
self.logger.debug("Nothing processed")
|
||||||
worst_nagiosstatus = nagiosStateUnknown
|
worst_nagiosstatus = nagiosStateUnknown
|
||||||
nagiosoutput = 'UNKNOWN : Unknown backup sections "%s"' % sections
|
nagiosoutput = 'UNKNOWN : Unknown backup sections "%s"' % sections
|
||||||
|
|
||||||
@@ -159,156 +169,154 @@ class tis_backup:
|
|||||||
if unknown:
|
if unknown:
|
||||||
if not worst_nagiosstatus:
|
if not worst_nagiosstatus:
|
||||||
worst_nagiosstatus = nagiosStateUnknown
|
worst_nagiosstatus = nagiosStateUnknown
|
||||||
nagiosoutput = 'UNKNOWN status backups %s' % (','.join([b[0] for b in unknown]))
|
nagiosoutput = "UNKNOWN status backups %s" % (",".join([b[0] for b in unknown]))
|
||||||
globallog.extend(unknown)
|
globallog.extend(unknown)
|
||||||
|
|
||||||
if critical:
|
if critical:
|
||||||
if not worst_nagiosstatus:
|
if not worst_nagiosstatus:
|
||||||
worst_nagiosstatus = nagiosStateCritical
|
worst_nagiosstatus = nagiosStateCritical
|
||||||
nagiosoutput = 'CRITICAL backups %s' % (','.join([b[0] for b in critical]))
|
nagiosoutput = "CRITICAL backups %s" % (",".join([b[0] for b in critical]))
|
||||||
globallog.extend(critical)
|
globallog.extend(critical)
|
||||||
|
|
||||||
if warning:
|
if warning:
|
||||||
if not worst_nagiosstatus:
|
if not worst_nagiosstatus:
|
||||||
worst_nagiosstatus = nagiosStateWarning
|
worst_nagiosstatus = nagiosStateWarning
|
||||||
nagiosoutput = 'WARNING backups %s' % (','.join([b[0] for b in warning]))
|
nagiosoutput = "WARNING backups %s" % (",".join([b[0] for b in warning]))
|
||||||
globallog.extend(warning)
|
globallog.extend(warning)
|
||||||
|
|
||||||
if ok:
|
if ok:
|
||||||
if not worst_nagiosstatus:
|
if not worst_nagiosstatus:
|
||||||
worst_nagiosstatus = nagiosStateOk
|
worst_nagiosstatus = nagiosStateOk
|
||||||
nagiosoutput = 'OK backups %s' % (','.join([b[0] for b in ok]))
|
nagiosoutput = "OK backups %s" % (",".join([b[0] for b in ok]))
|
||||||
globallog.extend(ok)
|
globallog.extend(ok)
|
||||||
|
|
||||||
if worst_nagiosstatus == nagiosStateOk:
|
if worst_nagiosstatus == nagiosStateOk:
|
||||||
nagiosoutput = 'ALL backups OK %s' % (','.join(sections))
|
nagiosoutput = "ALL backups OK %s" % (",".join(sections))
|
||||||
|
|
||||||
|
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
worst_nagiosstatus = nagiosStateCritical
|
worst_nagiosstatus = nagiosStateCritical
|
||||||
nagiosoutput = 'EXCEPTION',"Critical : %s" % str(e)
|
nagiosoutput = "EXCEPTION", "Critical : %s" % str(e)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
self.logger.debug('worst nagios status :"%i"',worst_nagiosstatus)
|
self.logger.debug('worst nagios status :"%i"', worst_nagiosstatus)
|
||||||
print('%s (tisbackup V%s)' %(nagiosoutput,version))
|
print("%s (tisbackup V%s)" % (nagiosoutput, version))
|
||||||
print('\n'.join(["[%s]:%s" % (l[0],l[1]) for l in globallog]))
|
print("\n".join(["[%s]:%s" % (log_elem[0], log_elem[1]) for log_elem in globallog]))
|
||||||
sys.exit(worst_nagiosstatus)
|
sys.exit(worst_nagiosstatus)
|
||||||
|
|
||||||
def process_backup(self,sections=[]):
|
def process_backup(self, sections=[]):
|
||||||
processed = []
|
processed = []
|
||||||
errors = []
|
errors = []
|
||||||
if not sections:
|
if not sections:
|
||||||
sections = [backup_item.backup_name for backup_item in self.backup_list]
|
sections = [backup_item.backup_name for backup_item in self.backup_list]
|
||||||
|
|
||||||
self.logger.info('Processing backup for %s' % (','.join(sections)) )
|
self.logger.info("Processing backup for %s" % (",".join(sections)))
|
||||||
for backup_item in self.backup_list:
|
for backup_item in self.backup_list:
|
||||||
if not sections or backup_item.backup_name in sections:
|
if not sections or backup_item.backup_name in sections:
|
||||||
try:
|
try:
|
||||||
assert(isinstance(backup_item,backup_generic))
|
assert isinstance(backup_item, backup_generic)
|
||||||
self.logger.info('Processing [%s]',(backup_item.backup_name))
|
self.logger.info("Processing [%s]", (backup_item.backup_name))
|
||||||
stats = backup_item.process_backup()
|
stats = backup_item.process_backup()
|
||||||
processed.append((backup_item.backup_name,stats))
|
processed.append((backup_item.backup_name, stats))
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
self.logger.critical('Backup [%s] processed with error : %s',backup_item.backup_name,e)
|
self.logger.critical("Backup [%s] processed with error : %s", backup_item.backup_name, e)
|
||||||
errors.append((backup_item.backup_name,str(e)))
|
errors.append((backup_item.backup_name, str(e)))
|
||||||
if not processed and not errors:
|
if not processed and not errors:
|
||||||
self.logger.critical('No backup properly finished or processed')
|
self.logger.critical("No backup properly finished or processed")
|
||||||
else:
|
else:
|
||||||
if processed:
|
if processed:
|
||||||
self.logger.info('Backup processed : %s' , ",".join([b[0] for b in processed]))
|
self.logger.info("Backup processed : %s", ",".join([b[0] for b in processed]))
|
||||||
if errors:
|
if errors:
|
||||||
self.logger.error('Backup processed with errors: %s' , ",".join([b[0] for b in errors]))
|
self.logger.error("Backup processed with errors: %s", ",".join([b[0] for b in errors]))
|
||||||
|
|
||||||
def export_backups(self,sections=[],exportdir=''):
|
def export_backups(self, sections=[], exportdir=""):
|
||||||
processed = []
|
processed = []
|
||||||
errors = []
|
errors = []
|
||||||
if not sections:
|
if not sections:
|
||||||
sections = [backup_item.backup_name for backup_item in self.backup_list]
|
sections = [backup_item.backup_name for backup_item in self.backup_list]
|
||||||
|
|
||||||
self.logger.info('Exporting OK backups for %s to %s' % (','.join(sections),exportdir) )
|
self.logger.info("Exporting OK backups for %s to %s" % (",".join(sections), exportdir))
|
||||||
|
|
||||||
for backup_item in self.backup_list:
|
for backup_item in self.backup_list:
|
||||||
if backup_item.backup_name in sections:
|
if backup_item.backup_name in sections:
|
||||||
try:
|
try:
|
||||||
assert(isinstance(backup_item,backup_generic))
|
assert isinstance(backup_item, backup_generic)
|
||||||
self.logger.info('Processing [%s]',(backup_item.backup_name))
|
self.logger.info("Processing [%s]", (backup_item.backup_name))
|
||||||
stats = backup_item.export_latestbackup(destdir=exportdir)
|
stats = backup_item.export_latestbackup(destdir=exportdir)
|
||||||
processed.append((backup_item.backup_name,stats))
|
processed.append((backup_item.backup_name, stats))
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
self.logger.critical('Export Backup [%s] processed with error : %s',backup_item.backup_name,e)
|
self.logger.critical("Export Backup [%s] processed with error : %s", backup_item.backup_name, e)
|
||||||
errors.append((backup_item.backup_name,str(e)))
|
errors.append((backup_item.backup_name, str(e)))
|
||||||
if not processed and not errors:
|
if not processed and not errors:
|
||||||
self.logger.critical('No export backup properly finished or processed')
|
self.logger.critical("No export backup properly finished or processed")
|
||||||
else:
|
else:
|
||||||
if processed:
|
if processed:
|
||||||
self.logger.info('Export Backups processed : %s' , ",".join([b[0] for b in processed]))
|
self.logger.info("Export Backups processed : %s", ",".join([b[0] for b in processed]))
|
||||||
if errors:
|
if errors:
|
||||||
self.logger.error('Export Backups processed with errors: %s' , ",".join([b[0] for b in errors]))
|
self.logger.error("Export Backups processed with errors: %s", ",".join([b[0] for b in errors]))
|
||||||
|
|
||||||
def retry_failed_backups(self,maxage_hours=30):
|
def retry_failed_backups(self, maxage_hours=30):
|
||||||
processed = []
|
processed = []
|
||||||
errors = []
|
errors = []
|
||||||
|
|
||||||
# before mindate, backup is too old
|
# before mindate, backup is too old
|
||||||
mindate = datetime2isodate((datetime.datetime.now() - datetime.timedelta(hours=maxage_hours)))
|
mindate = datetime2isodate((datetime.datetime.now() - datetime.timedelta(hours=maxage_hours)))
|
||||||
failed_backups = self.dbstat.query("""\
|
failed_backups = self.dbstat.query(
|
||||||
|
"""\
|
||||||
select distinct backup_name as bname
|
select distinct backup_name as bname
|
||||||
from stats
|
from stats
|
||||||
where status="OK" and backup_start>=?""",(mindate,))
|
where status="OK" and backup_start>=?""",
|
||||||
|
(mindate,),
|
||||||
|
)
|
||||||
defined_backups = list(map(lambda f:f.backup_name, [ x for x in self.backup_list if not isinstance(x, backup_null) ]))
|
|
||||||
failed_backups_names = set(defined_backups) - set([b['bname'] for b in failed_backups if b['bname'] in defined_backups])
|
|
||||||
|
|
||||||
|
defined_backups = list(map(lambda f: f.backup_name, [x for x in self.backup_list if not isinstance(x, backup_null)]))
|
||||||
|
failed_backups_names = set(defined_backups) - set([b["bname"] for b in failed_backups if b["bname"] in defined_backups])
|
||||||
|
|
||||||
if failed_backups_names:
|
if failed_backups_names:
|
||||||
self.logger.info('Processing backup for %s',','.join(failed_backups_names))
|
self.logger.info("Processing backup for %s", ",".join(failed_backups_names))
|
||||||
for backup_item in self.backup_list:
|
for backup_item in self.backup_list:
|
||||||
if backup_item.backup_name in failed_backups_names:
|
if backup_item.backup_name in failed_backups_names:
|
||||||
try:
|
try:
|
||||||
assert(isinstance(backup_item,backup_generic))
|
assert isinstance(backup_item, backup_generic)
|
||||||
self.logger.info('Processing [%s]',(backup_item.backup_name))
|
self.logger.info("Processing [%s]", (backup_item.backup_name))
|
||||||
stats = backup_item.process_backup()
|
stats = backup_item.process_backup()
|
||||||
processed.append((backup_item.backup_name,stats))
|
processed.append((backup_item.backup_name, stats))
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
self.logger.critical('Backup [%s] not processed, error : %s',backup_item.backup_name,e)
|
self.logger.critical("Backup [%s] not processed, error : %s", backup_item.backup_name, e)
|
||||||
errors.append((backup_item.backup_name,str(e)))
|
errors.append((backup_item.backup_name, str(e)))
|
||||||
if not processed and not errors:
|
if not processed and not errors:
|
||||||
self.logger.critical('No backup properly finished or processed')
|
self.logger.critical("No backup properly finished or processed")
|
||||||
else:
|
else:
|
||||||
if processed:
|
if processed:
|
||||||
self.logger.info('Backup processed : %s' , ",".join([b[0] for b in errors]))
|
self.logger.info("Backup processed : %s", ",".join([b[0] for b in errors]))
|
||||||
if errors:
|
if errors:
|
||||||
self.logger.error('Backup processed with errors: %s' , ",".join([b[0] for b in errors]))
|
self.logger.error("Backup processed with errors: %s", ",".join([b[0] for b in errors]))
|
||||||
else:
|
else:
|
||||||
self.logger.info('No recent failed backups found in database')
|
self.logger.info("No recent failed backups found in database")
|
||||||
|
|
||||||
|
def cleanup_backup_section(self, sections=[]):
|
||||||
def cleanup_backup_section(self,sections = []):
|
|
||||||
log = ''
|
|
||||||
processed = False
|
processed = False
|
||||||
if not sections:
|
if not sections:
|
||||||
sections = [backup_item.backup_name for backup_item in self.backup_list]
|
sections = [backup_item.backup_name for backup_item in self.backup_list]
|
||||||
|
|
||||||
self.logger.info('Processing cleanup for %s' % (','.join(sections)) )
|
self.logger.info("Processing cleanup for %s" % (",".join(sections)))
|
||||||
for backup_item in self.backup_list:
|
for backup_item in self.backup_list:
|
||||||
if backup_item.backup_name in sections:
|
if backup_item.backup_name in sections:
|
||||||
try:
|
try:
|
||||||
assert(isinstance(backup_item,backup_generic))
|
assert isinstance(backup_item, backup_generic)
|
||||||
self.logger.info('Processing cleanup of [%s]',(backup_item.backup_name))
|
self.logger.info("Processing cleanup of [%s]", (backup_item.backup_name))
|
||||||
backup_item.cleanup_backup()
|
backup_item.cleanup_backup()
|
||||||
processed = True
|
processed = True
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
self.logger.critical('Cleanup of [%s] not processed, error : %s',backup_item.backup_name,e)
|
self.logger.critical("Cleanup of [%s] not processed, error : %s", backup_item.backup_name, e)
|
||||||
if not processed:
|
if not processed:
|
||||||
self.logger.critical('No cleanup properly finished or processed')
|
self.logger.critical("No cleanup properly finished or processed")
|
||||||
|
|
||||||
def register_existingbackups(self,sections = []):
|
def register_existingbackups(self, sections=[]):
|
||||||
if not sections:
|
if not sections:
|
||||||
sections = [backup_item.backup_name for backup_item in self.backup_list]
|
sections = [backup_item.backup_name for backup_item in self.backup_list]
|
||||||
|
|
||||||
self.logger.info('Append existing backups to database...')
|
self.logger.info("Append existing backups to database...")
|
||||||
for backup_item in self.backup_list:
|
for backup_item in self.backup_list:
|
||||||
if backup_item.backup_name in sections:
|
if backup_item.backup_name in sections:
|
||||||
backup_item.register_existingbackups()
|
backup_item.register_existingbackups()
|
||||||
@@ -316,26 +324,26 @@ class tis_backup:
|
|||||||
def html_report(self):
|
def html_report(self):
|
||||||
for backup_item in self.backup_list:
|
for backup_item in self.backup_list:
|
||||||
if not section or section == backup_item.backup_name:
|
if not section or section == backup_item.backup_name:
|
||||||
assert(isinstance(backup_item,backup_generic))
|
assert isinstance(backup_item, backup_generic)
|
||||||
if not maxage_hours:
|
if not maxage_hours:
|
||||||
maxage_hours = backup_item.maximum_backup_age
|
maxage_hours = backup_item.maximum_backup_age
|
||||||
(nagiosstatus,log) = backup_item.checknagios(maxage_hours=maxage_hours)
|
(nagiosstatus, log) = backup_item.checknagios(maxage_hours=maxage_hours)
|
||||||
globallog.append('[%s] %s' % (backup_item.backup_name,log))
|
globallog.append("[%s] %s" % (backup_item.backup_name, log))
|
||||||
self.logger.debug('[%s] nagios:"%i" log: %s',backup_item.backup_name,nagiosstatus,log)
|
self.logger.debug('[%s] nagios:"%i" log: %s', backup_item.backup_name, nagiosstatus, log)
|
||||||
processed = True
|
# processed = True
|
||||||
if nagiosstatus >= worst_nagiosstatus:
|
# if nagiosstatus >= worst_nagiosstatus:
|
||||||
worst_nagiosstatus = nagiosstatus
|
# worst_nagiosstatus = nagiosstatus
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
(options,args)=parser.parse_args()
|
(options, args) = parser.parse_args()
|
||||||
|
|
||||||
if len(args) != 1:
|
if len(args) != 1:
|
||||||
print("ERROR : You must provide one action to perform")
|
print("ERROR : You must provide one action to perform")
|
||||||
parser.print_usage()
|
parser.print_usage()
|
||||||
sys.exit(2)
|
sys.exit(2)
|
||||||
|
|
||||||
backup_start_date = datetime.datetime.now().strftime('%Y%m%d-%Hh%Mm%S')
|
backup_start_date = datetime.datetime.now().strftime("%Y%m%d-%Hh%Mm%S")
|
||||||
|
|
||||||
# options
|
# options
|
||||||
action = args[0]
|
action = args[0]
|
||||||
@@ -344,23 +352,23 @@ def main():
|
|||||||
print(backup_drivers[t].get_help())
|
print(backup_drivers[t].get_help())
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
config_file =options.config
|
config_file = options.config
|
||||||
dry_run = options.dry_run
|
dry_run = options.dry_run
|
||||||
verbose = options.verbose
|
verbose = options.verbose
|
||||||
|
|
||||||
loglevel = options.loglevel
|
loglevel = options.loglevel
|
||||||
|
|
||||||
# setup Logger
|
# setup Logger
|
||||||
logger = logging.getLogger('tisbackup')
|
logger = logging.getLogger("tisbackup")
|
||||||
hdlr = logging.StreamHandler()
|
hdlr = logging.StreamHandler()
|
||||||
hdlr.setFormatter(logging.Formatter('%(asctime)s %(levelname)s %(message)s'))
|
hdlr.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
||||||
logger.addHandler(hdlr)
|
logger.addHandler(hdlr)
|
||||||
|
|
||||||
# set loglevel
|
# set loglevel
|
||||||
if loglevel in ('debug','warning','info','error','critical'):
|
if loglevel in ("debug", "warning", "info", "error", "critical"):
|
||||||
numeric_level = getattr(logging, loglevel.upper(), None)
|
numeric_level = getattr(logging, loglevel.upper(), None)
|
||||||
if not isinstance(numeric_level, int):
|
if not isinstance(numeric_level, int):
|
||||||
raise ValueError('Invalid log level: %s' % loglevel)
|
raise ValueError("Invalid log level: %s" % loglevel)
|
||||||
logger.setLevel(numeric_level)
|
logger.setLevel(numeric_level)
|
||||||
|
|
||||||
# Config file
|
# Config file
|
||||||
@@ -371,36 +379,36 @@ def main():
|
|||||||
cp = ConfigParser()
|
cp = ConfigParser()
|
||||||
cp.read(config_file)
|
cp.read(config_file)
|
||||||
|
|
||||||
backup_base_dir = options.backup_base_dir or cp.get('global','backup_base_dir')
|
backup_base_dir = options.backup_base_dir or cp.get("global", "backup_base_dir")
|
||||||
log_dir = os.path.join(backup_base_dir,'log')
|
log_dir = os.path.join(backup_base_dir, "log")
|
||||||
if not os.path.exists(log_dir):
|
if not os.path.exists(log_dir):
|
||||||
os.makedirs(log_dir)
|
os.makedirs(log_dir)
|
||||||
|
|
||||||
# if we run the nagios check, we don't create log file, everything is piped to stdout
|
# if we run the nagios check, we don't create log file, everything is piped to stdout
|
||||||
if action!='checknagios':
|
if action != "checknagios":
|
||||||
try:
|
try:
|
||||||
hdlr = logging.FileHandler(os.path.join(log_dir,'tisbackup_%s.log' % (backup_start_date)))
|
hdlr = logging.FileHandler(os.path.join(log_dir, "tisbackup_%s.log" % (backup_start_date)))
|
||||||
hdlr.setFormatter(logging.Formatter('%(asctime)s %(levelname)s %(message)s'))
|
hdlr.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
||||||
logger.addHandler(hdlr)
|
logger.addHandler(hdlr)
|
||||||
except IOError as e:
|
except IOError as e:
|
||||||
if action == 'cleanup' and e.errno == errno.ENOSPC:
|
if action == "cleanup" and e.errno == errno.ENOSPC:
|
||||||
logger.warning("No space left on device, disabling file logging.")
|
logger.warning("No space left on device, disabling file logging.")
|
||||||
else:
|
else:
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
# Main
|
# Main
|
||||||
backup = tis_backup(dry_run=dry_run,verbose=verbose,backup_base_dir=backup_base_dir)
|
backup = tis_backup(dry_run=dry_run, verbose=verbose, backup_base_dir=backup_base_dir)
|
||||||
backup.read_ini_file(config_file)
|
backup.read_ini_file(config_file)
|
||||||
|
|
||||||
backup_sections = options.sections.split(',') if options.sections else []
|
backup_sections = options.sections.split(",") if options.sections else []
|
||||||
|
|
||||||
all_sections = [backup_item.backup_name for backup_item in backup.backup_list]
|
all_sections = [backup_item.backup_name for backup_item in backup.backup_list]
|
||||||
if not backup_sections:
|
if not backup_sections:
|
||||||
backup_sections = all_sections
|
backup_sections = all_sections
|
||||||
else:
|
else:
|
||||||
for b in backup_sections:
|
for b in backup_sections:
|
||||||
if not b in all_sections:
|
if b not in all_sections:
|
||||||
raise Exception('Section %s is not defined in config file' % b)
|
raise Exception("Section %s is not defined in config file" % b)
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
logger.warning("WARNING : DRY RUN, nothing will be done, just printing on screen...")
|
logger.warning("WARNING : DRY RUN, nothing will be done, just printing on screen...")
|
||||||
@@ -409,23 +417,22 @@ def main():
|
|||||||
backup.process_backup(backup_sections)
|
backup.process_backup(backup_sections)
|
||||||
elif action == "exportbackup":
|
elif action == "exportbackup":
|
||||||
if not options.exportdir:
|
if not options.exportdir:
|
||||||
raise Exception('No export directory supplied dor exportbackup action')
|
raise Exception("No export directory supplied dor exportbackup action")
|
||||||
backup.export_backups(backup_sections,options.exportdir)
|
backup.export_backups(backup_sections, options.exportdir)
|
||||||
elif action == "cleanup":
|
elif action == "cleanup":
|
||||||
backup.cleanup_backup_section(backup_sections)
|
backup.cleanup_backup_section(backup_sections)
|
||||||
elif action == "checknagios":
|
elif action == "checknagios":
|
||||||
backup.checknagios(backup_sections)
|
backup.checknagios(backup_sections)
|
||||||
elif action == "dumpstat":
|
elif action == "dumpstat":
|
||||||
for s in backup_sections:
|
for s in backup_sections:
|
||||||
backup.dbstat.last_backups(s,count=options.statscount)
|
backup.dbstat.last_backups(s, count=options.statscount)
|
||||||
elif action == "retryfailed":
|
elif action == "retryfailed":
|
||||||
backup.retry_failed_backups()
|
backup.retry_failed_backups()
|
||||||
elif action == "register_existing":
|
elif action == "register_existing":
|
||||||
backup.register_existingbackups(backup_sections)
|
backup.register_existingbackups(backup_sections)
|
||||||
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
logger.error('Unhandled action "%s", quitting...',action)
|
logger.error('Unhandled action "%s", quitting...', action)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+379
-193
@@ -17,89 +17,169 @@
|
|||||||
# along with TISBackup. If not, see <http://www.gnu.org/licenses/>.
|
# along with TISBackup. If not, see <http://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
import os,sys
|
import os
|
||||||
|
import sys
|
||||||
from os.path import isfile, join
|
from os.path import isfile, join
|
||||||
|
|
||||||
tisbackup_root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__)))
|
tisbackup_root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__)))
|
||||||
sys.path.append(os.path.join(tisbackup_root_dir,'lib'))
|
sys.path.append(os.path.join(tisbackup_root_dir, "lib"))
|
||||||
sys.path.append(os.path.join(tisbackup_root_dir,'libtisbackup'))
|
sys.path.append(os.path.join(tisbackup_root_dir, "libtisbackup"))
|
||||||
|
|
||||||
|
|
||||||
from shutil import *
|
|
||||||
from iniparse import ConfigParser,RawConfigParser
|
|
||||||
from libtisbackup.common import *
|
|
||||||
import time
|
|
||||||
from flask import request, Flask, session, g, appcontext_pushed, redirect, url_for, abort, render_template, flash, jsonify, Response
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
import json
|
|
||||||
import glob
|
import glob
|
||||||
import time
|
import json
|
||||||
|
|
||||||
from config import huey
|
|
||||||
from tasks import run_export_backup, get_task, set_task
|
|
||||||
|
|
||||||
from tisbackup import tis_backup
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from flask import Flask, Response, abort, appcontext_pushed, flash, g, jsonify, redirect, render_template, request, session, url_for
|
||||||
|
from iniparse import ConfigParser, RawConfigParser
|
||||||
|
|
||||||
|
from config import huey
|
||||||
|
from libtisbackup import *
|
||||||
|
from libtisbackup.auth import get_auth_provider
|
||||||
|
from tasks import get_task, run_export_backup, set_task
|
||||||
|
from tisbackup import tis_backup
|
||||||
|
|
||||||
cp = ConfigParser()
|
cp = ConfigParser()
|
||||||
cp.read("/etc/tis/tisbackup_gui.ini")
|
cp.read("/etc/tis/tisbackup_gui.ini")
|
||||||
|
|
||||||
CONFIG = cp.get('general','config_tisbackup').split(",")
|
CONFIG = cp.get("general", "config_tisbackup").split(",")
|
||||||
SECTIONS = cp.get('general','sections')
|
SECTIONS = cp.get("general", "sections")
|
||||||
ADMIN_EMAIL = cp.get('general','ADMIN_EMAIL')
|
ADMIN_EMAIL = cp.get("general", "ADMIN_EMAIL")
|
||||||
BASE_DIR = cp.get('general','base_config_dir')
|
BASE_DIR = cp.get("general", "base_config_dir")
|
||||||
|
|
||||||
tisbackup_config_file= CONFIG[0]
|
tisbackup_config_file = CONFIG[0]
|
||||||
config_number=0
|
config_number = 0
|
||||||
|
|
||||||
cp = ConfigParser()
|
cp = ConfigParser()
|
||||||
cp.read(tisbackup_config_file)
|
cp.read(tisbackup_config_file)
|
||||||
backup_base_dir = cp.get('global','backup_base_dir')
|
backup_base_dir = cp.get("global", "backup_base_dir")
|
||||||
dbstat = BackupStat(os.path.join(backup_base_dir,'log','tisbackup.sqlite'))
|
dbstat = BackupStat(os.path.join(backup_base_dir, "log", "tisbackup.sqlite"))
|
||||||
mindate = None
|
mindate = None
|
||||||
error = None
|
error = None
|
||||||
info = None
|
info = None
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.secret_key = 'fsiqefiuqsefARZ4Zfesfe34234dfzefzfe'
|
|
||||||
app.config['PROPAGATE_EXCEPTIONS'] = True
|
|
||||||
|
|
||||||
tasks_db = os.path.join(tisbackup_root_dir,"tasks.sqlite")
|
# Load secret key from environment variable or generate a secure random one
|
||||||
|
SECRET_KEY = os.environ.get("TISBACKUP_SECRET_KEY")
|
||||||
|
if not SECRET_KEY:
|
||||||
|
# Generate a secure random secret key if not provided
|
||||||
|
import secrets
|
||||||
|
SECRET_KEY = secrets.token_hex(32)
|
||||||
|
# Warn if using a random key (sessions won't persist across restarts)
|
||||||
|
logging.warning(
|
||||||
|
"TISBACKUP_SECRET_KEY environment variable not set. Using a randomly generated secret key. "
|
||||||
|
"Sessions will not persist across application restarts. "
|
||||||
|
"Set TISBACKUP_SECRET_KEY environment variable for production use."
|
||||||
|
)
|
||||||
|
|
||||||
|
app.secret_key = SECRET_KEY
|
||||||
|
app.config["PROPAGATE_EXCEPTIONS"] = True
|
||||||
|
|
||||||
|
# Initialize authentication
|
||||||
|
auth_config = {}
|
||||||
|
try:
|
||||||
|
# Read authentication config from tisbackup_gui.ini
|
||||||
|
cp_gui = ConfigParser()
|
||||||
|
cp_gui.read("/etc/tis/tisbackup_gui.ini")
|
||||||
|
|
||||||
|
if cp_gui.has_section("authentication"):
|
||||||
|
auth_type = cp_gui.get("authentication", "type", fallback="basic")
|
||||||
|
|
||||||
|
# Load auth provider config
|
||||||
|
for key, value in cp_gui.items("authentication"):
|
||||||
|
if key != "type":
|
||||||
|
auth_config[key] = value
|
||||||
|
else:
|
||||||
|
# Default to Basic Auth if no config section
|
||||||
|
auth_type = "basic"
|
||||||
|
|
||||||
|
# Get credentials from environment or use defaults
|
||||||
|
default_username = os.environ.get("TISBACKUP_AUTH_USERNAME", "admin")
|
||||||
|
default_password = os.environ.get("TISBACKUP_AUTH_PASSWORD")
|
||||||
|
|
||||||
|
if not default_password:
|
||||||
|
# Generate random password if not set
|
||||||
|
import secrets
|
||||||
|
default_password = secrets.token_urlsafe(16)
|
||||||
|
logging.warning(
|
||||||
|
f"TISBACKUP_AUTH_PASSWORD not set. Generated temporary password for user '{default_username}': {default_password}"
|
||||||
|
)
|
||||||
|
logging.warning(
|
||||||
|
"Set TISBACKUP_AUTH_USERNAME and TISBACKUP_AUTH_PASSWORD environment variables, "
|
||||||
|
"or add [authentication] section to tisbackup_gui.ini for production use."
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_config = {
|
||||||
|
"username": default_username,
|
||||||
|
"password": default_password,
|
||||||
|
"use_bcrypt": False, # Plain text for auto-generated password
|
||||||
|
"realm": "TISBackup"
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Fallback to basic auth on error
|
||||||
|
logging.error(f"Error loading authentication config: {e}. Using default Basic Auth.")
|
||||||
|
auth_type = "basic"
|
||||||
|
auth_config = {
|
||||||
|
"username": os.environ.get("TISBACKUP_AUTH_USERNAME", "admin"),
|
||||||
|
"password": os.environ.get("TISBACKUP_AUTH_PASSWORD", "changeme"),
|
||||||
|
"use_bcrypt": False,
|
||||||
|
"realm": "TISBackup"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Initialize auth provider
|
||||||
|
try:
|
||||||
|
auth = get_auth_provider(auth_type, auth_config)
|
||||||
|
auth.init_app(app)
|
||||||
|
logging.info(f"Authentication initialized: {auth_type}")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Failed to initialize authentication: {e}")
|
||||||
|
# Fallback to no auth
|
||||||
|
auth = get_auth_provider("none", {})
|
||||||
|
logging.warning("Authentication disabled due to initialization error")
|
||||||
|
|
||||||
|
tasks_db = os.path.join(tisbackup_root_dir, "tasks.sqlite")
|
||||||
|
|
||||||
|
|
||||||
def read_all_configs(base_dir):
|
def read_all_configs(base_dir):
|
||||||
raw_configs = []
|
raw_configs = []
|
||||||
list_config = []
|
list_config = []
|
||||||
config_base_dir = base_dir
|
# config_base_dir = base_dir
|
||||||
|
|
||||||
for file in os.listdir(base_dir):
|
for file in os.listdir(base_dir):
|
||||||
if isfile(join(base_dir,file)):
|
if isfile(join(base_dir, file)):
|
||||||
raw_configs.append(join(base_dir,file))
|
raw_configs.append(join(base_dir, file))
|
||||||
|
|
||||||
for elem in raw_configs:
|
for elem in raw_configs:
|
||||||
line = open(elem).readline()
|
with open(elem) as f:
|
||||||
if 'global' in line:
|
line = f.readline()
|
||||||
list_config.append(elem)
|
if "global" in line:
|
||||||
|
list_config.append(elem)
|
||||||
|
|
||||||
backup_dict = {}
|
backup_dict = {}
|
||||||
backup_dict['rsync_ssh_list'] = []
|
backup_dict["rsync_ssh_list"] = []
|
||||||
backup_dict['rsync_btrfs_list'] = []
|
backup_dict["rsync_btrfs_list"] = []
|
||||||
backup_dict['rsync_list'] = []
|
backup_dict["rsync_list"] = []
|
||||||
backup_dict['null_list'] = []
|
backup_dict["null_list"] = []
|
||||||
backup_dict['pgsql_list'] = []
|
backup_dict["pgsql_list"] = []
|
||||||
backup_dict['mysql_list'] = []
|
backup_dict["mysql_list"] = []
|
||||||
#backup_dict['sqlserver_list'] = []
|
# backup_dict['sqlserver_list'] = []
|
||||||
backup_dict['xva_list'] = []
|
backup_dict["xva_list"] = []
|
||||||
backup_dict['metadata_list'] = []
|
backup_dict["metadata_list"] = []
|
||||||
#backup_dict['switch_list'] = []
|
# backup_dict['switch_list'] = []
|
||||||
#backup_dict['oracle_list'] = []
|
# backup_dict['oracle_list'] = []
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
cp = ConfigParser()
|
cp = ConfigParser()
|
||||||
for config_file in list_config:
|
for config_file in list_config:
|
||||||
cp.read(config_file)
|
cp.read(config_file)
|
||||||
|
|
||||||
backup_base_dir = cp.get('global', 'backup_base_dir')
|
backup_base_dir = cp.get("global", "backup_base_dir")
|
||||||
backup = tis_backup(backup_base_dir=backup_base_dir)
|
backup = tis_backup(backup_base_dir=backup_base_dir)
|
||||||
backup.read_ini_file(config_file)
|
backup.read_ini_file(config_file)
|
||||||
|
|
||||||
@@ -110,11 +190,12 @@ def read_all_configs(base_dir):
|
|||||||
backup_sections = all_sections
|
backup_sections = all_sections
|
||||||
else:
|
else:
|
||||||
for b in backup_sections:
|
for b in backup_sections:
|
||||||
if not b in all_sections:
|
if b not in all_sections:
|
||||||
raise Exception('Section %s is not defined in config file' % b)
|
raise Exception("Section %s is not defined in config file" % b)
|
||||||
|
|
||||||
if not backup_sections:
|
# never used..
|
||||||
sections = [backup_item.backup_name for backup_item in backup.backup_list]
|
# if not backup_sections:
|
||||||
|
# sections = [backup_item.backup_name for backup_item in backup.backup_list]
|
||||||
|
|
||||||
for backup_item in backup.backup_list:
|
for backup_item in backup.backup_list:
|
||||||
if backup_item.backup_name in backup_sections:
|
if backup_item.backup_name in backup_sections:
|
||||||
@@ -125,35 +206,28 @@ def read_all_configs(base_dir):
|
|||||||
result.append(b)
|
result.append(b)
|
||||||
|
|
||||||
for row in result:
|
for row in result:
|
||||||
backup_name = row['backup_name']
|
backup_name = row["backup_name"]
|
||||||
server_name = row['server_name']
|
server_name = row["server_name"]
|
||||||
backup_type = row['type']
|
backup_type = row["type"]
|
||||||
if backup_type == "xcp-dump-metadata":
|
if backup_type == "xcp-dump-metadata":
|
||||||
backup_dict['metadata_list'].append(
|
backup_dict["metadata_list"].append([server_name, backup_name, backup_type, ""])
|
||||||
[server_name, backup_name, backup_type, ""])
|
|
||||||
if backup_type == "rsync+ssh":
|
if backup_type == "rsync+ssh":
|
||||||
remote_dir = row['remote_dir']
|
remote_dir = row["remote_dir"]
|
||||||
backup_dict['rsync_ssh_list'].append(
|
backup_dict["rsync_ssh_list"].append([server_name, backup_name, backup_type, remote_dir])
|
||||||
[server_name, backup_name, backup_type, remote_dir])
|
|
||||||
if backup_type == "rsync+btrfs+ssh":
|
if backup_type == "rsync+btrfs+ssh":
|
||||||
remote_dir = row['remote_dir']
|
remote_dir = row["remote_dir"]
|
||||||
backup_dict['rsync_btrfs_list'].append(
|
backup_dict["rsync_btrfs_list"].append([server_name, backup_name, backup_type, remote_dir])
|
||||||
[server_name, backup_name, backup_type, remote_dir])
|
|
||||||
if backup_type == "rsync":
|
if backup_type == "rsync":
|
||||||
remote_dir = row['remote_dir']
|
remote_dir = row["remote_dir"]
|
||||||
backup_dict['rsync_list'].append(
|
backup_dict["rsync_list"].append([server_name, backup_name, backup_type, remote_dir])
|
||||||
[server_name, backup_name, backup_type, remote_dir])
|
|
||||||
if backup_type == "null":
|
if backup_type == "null":
|
||||||
backup_dict['null_list'].append(
|
backup_dict["null_list"].append([server_name, backup_name, backup_type, ""])
|
||||||
[server_name, backup_name, backup_type, ""])
|
|
||||||
if backup_type == "pgsql+ssh":
|
if backup_type == "pgsql+ssh":
|
||||||
db_name = row['db_name'] if len(row['db_name']) > 0 else '*'
|
db_name = row["db_name"] if len(row["db_name"]) > 0 else "*"
|
||||||
backup_dict['pgsql_list'].append(
|
backup_dict["pgsql_list"].append([server_name, backup_name, backup_type, db_name])
|
||||||
[server_name, backup_name, backup_type, db_name])
|
|
||||||
if backup_type == "mysql+ssh":
|
if backup_type == "mysql+ssh":
|
||||||
db_name = row['db_name'] if len(row['db_name']) > 0 else '*'
|
db_name = row["db_name"] if len(row["db_name"]) > 0 else "*"
|
||||||
backup_dict['mysql_list'].append(
|
backup_dict["mysql_list"].append([server_name, backup_name, backup_type, db_name])
|
||||||
[server_name, backup_name, backup_type, db_name])
|
|
||||||
# if backup_type == "sqlserver+ssh":
|
# if backup_type == "sqlserver+ssh":
|
||||||
# db_name = row['db_name']
|
# db_name = row['db_name']
|
||||||
# backup_dict['sqlserver_list'].append(
|
# backup_dict['sqlserver_list'].append(
|
||||||
@@ -163,8 +237,7 @@ def read_all_configs(base_dir):
|
|||||||
# backup_dict['oracle_list'].append(
|
# backup_dict['oracle_list'].append(
|
||||||
# [server_name, backup_name, backup_type, db_name])
|
# [server_name, backup_name, backup_type, db_name])
|
||||||
if backup_type == "xen-xva":
|
if backup_type == "xen-xva":
|
||||||
backup_dict['xva_list'].append(
|
backup_dict["xva_list"].append([server_name, backup_name, backup_type, ""])
|
||||||
[server_name, backup_name, backup_type, ""])
|
|
||||||
# if backup_type == "switch":
|
# if backup_type == "switch":
|
||||||
# backup_dict['switch_list'].append(
|
# backup_dict['switch_list'].append(
|
||||||
# [server_name, backup_name, backup_type, ""])
|
# [server_name, backup_name, backup_type, ""])
|
||||||
@@ -177,7 +250,7 @@ def read_config():
|
|||||||
cp = ConfigParser()
|
cp = ConfigParser()
|
||||||
cp.read(config_file)
|
cp.read(config_file)
|
||||||
|
|
||||||
backup_base_dir = cp.get('global','backup_base_dir')
|
backup_base_dir = cp.get("global", "backup_base_dir")
|
||||||
backup = tis_backup(backup_base_dir=backup_base_dir)
|
backup = tis_backup(backup_base_dir=backup_base_dir)
|
||||||
backup.read_ini_file(config_file)
|
backup.read_ini_file(config_file)
|
||||||
|
|
||||||
@@ -188,56 +261,58 @@ def read_config():
|
|||||||
backup_sections = all_sections
|
backup_sections = all_sections
|
||||||
else:
|
else:
|
||||||
for b in backup_sections:
|
for b in backup_sections:
|
||||||
if not b in all_sections:
|
if b not in all_sections:
|
||||||
raise Exception('Section %s is not defined in config file' % b)
|
raise Exception("Section %s is not defined in config file" % b)
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
if not backup_sections:
|
|
||||||
sections = [backup_item.backup_name for backup_item in backup.backup_list]
|
# not used ...
|
||||||
|
# if not backup_sections:
|
||||||
|
# sections = [backup_item.backup_name for backup_item in backup.backup_list]
|
||||||
|
|
||||||
for backup_item in backup.backup_list:
|
for backup_item in backup.backup_list:
|
||||||
if backup_item.backup_name in backup_sections:
|
if backup_item.backup_name in backup_sections:
|
||||||
b = {}
|
b = {}
|
||||||
for attrib_name in backup_item.required_params+backup_item.optional_params:
|
for attrib_name in backup_item.required_params + backup_item.optional_params:
|
||||||
if hasattr(backup_item,attrib_name):
|
if hasattr(backup_item, attrib_name):
|
||||||
b[attrib_name] = getattr(backup_item,attrib_name)
|
b[attrib_name] = getattr(backup_item, attrib_name)
|
||||||
result.append(b)
|
result.append(b)
|
||||||
|
|
||||||
backup_dict = {}
|
backup_dict = {}
|
||||||
backup_dict['rsync_ssh_list'] = []
|
backup_dict["rsync_ssh_list"] = []
|
||||||
backup_dict['rsync_btrfs_list'] = []
|
backup_dict["rsync_btrfs_list"] = []
|
||||||
backup_dict['rsync_list'] = []
|
backup_dict["rsync_list"] = []
|
||||||
backup_dict['null_list'] = []
|
backup_dict["null_list"] = []
|
||||||
backup_dict['pgsql_list'] = []
|
backup_dict["pgsql_list"] = []
|
||||||
backup_dict['mysql_list'] = []
|
backup_dict["mysql_list"] = []
|
||||||
#backup_dict['sqlserver_list'] = []
|
# backup_dict['sqlserver_list'] = []
|
||||||
backup_dict['xva_list'] = []
|
backup_dict["xva_list"] = []
|
||||||
backup_dict['metadata_list'] = []
|
backup_dict["metadata_list"] = []
|
||||||
#backup_dict['switch_list'] = []
|
# backup_dict['switch_list'] = []
|
||||||
#backup_dict['oracle_list'] = []
|
# backup_dict['oracle_list'] = []
|
||||||
for row in result:
|
for row in result:
|
||||||
backup_name = row['backup_name']
|
backup_name = row["backup_name"]
|
||||||
server_name = row['server_name']
|
server_name = row["server_name"]
|
||||||
backup_type = row['type']
|
backup_type = row["type"]
|
||||||
if backup_type == "xcp-dump-metadata":
|
if backup_type == "xcp-dump-metadata":
|
||||||
backup_dict['metadata_list'].append([server_name, backup_name, backup_type, ""])
|
backup_dict["metadata_list"].append([server_name, backup_name, backup_type, ""])
|
||||||
if backup_type == "rsync+ssh":
|
if backup_type == "rsync+ssh":
|
||||||
remote_dir = row['remote_dir']
|
remote_dir = row["remote_dir"]
|
||||||
backup_dict['rsync_ssh_list'].append([server_name, backup_name, backup_type,remote_dir])
|
backup_dict["rsync_ssh_list"].append([server_name, backup_name, backup_type, remote_dir])
|
||||||
if backup_type == "rsync+btrfs+ssh":
|
if backup_type == "rsync+btrfs+ssh":
|
||||||
remote_dir = row['remote_dir']
|
remote_dir = row["remote_dir"]
|
||||||
backup_dict['rsync_btrfs_list'].append([server_name, backup_name, backup_type,remote_dir])
|
backup_dict["rsync_btrfs_list"].append([server_name, backup_name, backup_type, remote_dir])
|
||||||
if backup_type == "rsync":
|
if backup_type == "rsync":
|
||||||
remote_dir = row['remote_dir']
|
remote_dir = row["remote_dir"]
|
||||||
backup_dict['rsync_list'].append([server_name, backup_name, backup_type,remote_dir])
|
backup_dict["rsync_list"].append([server_name, backup_name, backup_type, remote_dir])
|
||||||
if backup_type == "null":
|
if backup_type == "null":
|
||||||
backup_dict['null_list'].append([server_name, backup_name, backup_type, ""])
|
backup_dict["null_list"].append([server_name, backup_name, backup_type, ""])
|
||||||
if backup_type == "pgsql+ssh":
|
if backup_type == "pgsql+ssh":
|
||||||
db_name = row['db_name'] if len(row['db_name']) > 0 else '*'
|
db_name = row["db_name"] if len(row["db_name"]) > 0 else "*"
|
||||||
backup_dict['pgsql_list'].append([server_name, backup_name, backup_type, db_name])
|
backup_dict["pgsql_list"].append([server_name, backup_name, backup_type, db_name])
|
||||||
if backup_type == "mysql+ssh":
|
if backup_type == "mysql+ssh":
|
||||||
db_name = row['db_name'] if len(row['db_name']) > 0 else '*'
|
db_name = row["db_name"] if len(row["db_name"]) > 0 else "*"
|
||||||
backup_dict['mysql_list'].append([server_name, backup_name, backup_type, db_name])
|
backup_dict["mysql_list"].append([server_name, backup_name, backup_type, db_name])
|
||||||
# if backup_type == "sqlserver+ssh":
|
# if backup_type == "sqlserver+ssh":
|
||||||
# db_name = row['db_name']
|
# db_name = row['db_name']
|
||||||
# backup_dict['sqlserver_list'].append([server_name, backup_name, backup_type, db_name])
|
# backup_dict['sqlserver_list'].append([server_name, backup_name, backup_type, db_name])
|
||||||
@@ -245,49 +320,86 @@ def read_config():
|
|||||||
# db_name = row['db_name']
|
# db_name = row['db_name']
|
||||||
# backup_dict['oracle_list'].append([server_name, backup_name, backup_type, db_name])
|
# backup_dict['oracle_list'].append([server_name, backup_name, backup_type, db_name])
|
||||||
if backup_type == "xen-xva":
|
if backup_type == "xen-xva":
|
||||||
backup_dict['xva_list'].append([server_name, backup_name, backup_type, ""])
|
backup_dict["xva_list"].append([server_name, backup_name, backup_type, ""])
|
||||||
# if backup_type == "switch":
|
# if backup_type == "switch":
|
||||||
# backup_dict['switch_list'].append([server_name, backup_name, backup_type, ""])
|
# backup_dict['switch_list'].append([server_name, backup_name, backup_type, ""])
|
||||||
return backup_dict
|
return backup_dict
|
||||||
|
|
||||||
@app.route('/')
|
|
||||||
|
@app.route("/")
|
||||||
|
@auth.require_auth
|
||||||
def backup_all():
|
def backup_all():
|
||||||
backup_dict = read_config()
|
backup_dict = read_config()
|
||||||
return render_template('backups.html', backup_list = backup_dict)
|
return render_template("backups.html", backup_list=backup_dict)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/config_number/')
|
@app.route("/config_number/")
|
||||||
@app.route('/config_number/<int:id>')
|
@app.route("/config_number/<int:id>")
|
||||||
|
@auth.require_auth
|
||||||
def set_config_number(id=None):
|
def set_config_number(id=None):
|
||||||
if id != None and len(CONFIG) > id:
|
if id is not None and len(CONFIG) > id:
|
||||||
global config_number
|
global config_number
|
||||||
config_number=id
|
config_number = id
|
||||||
read_config()
|
read_config()
|
||||||
return jsonify(configs=CONFIG,config_number=config_number)
|
return jsonify(configs=CONFIG, config_number=config_number)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/all_json')
|
@app.route("/all_json")
|
||||||
|
@auth.require_auth
|
||||||
def backup_all_json():
|
def backup_all_json():
|
||||||
backup_dict = read_all_configs(BASE_DIR)
|
backup_dict = read_all_configs(BASE_DIR)
|
||||||
return json.dumps(backup_dict['rsync_list']+backup_dict['rsync_btrfs_list']+backup_dict['rsync_ssh_list']+backup_dict['pgsql_list']+backup_dict['mysql_list']+backup_dict['xva_list']+backup_dict['null_list']+backup_dict['metadata_list'])
|
return json.dumps(
|
||||||
#+ backup_dict['switch_list'])+backup_dict['sqlserver_list']
|
backup_dict["rsync_list"]
|
||||||
|
+ backup_dict["rsync_btrfs_list"]
|
||||||
|
+ backup_dict["rsync_ssh_list"]
|
||||||
|
+ backup_dict["pgsql_list"]
|
||||||
|
+ backup_dict["mysql_list"]
|
||||||
|
+ backup_dict["xva_list"]
|
||||||
|
+ backup_dict["null_list"]
|
||||||
|
+ backup_dict["metadata_list"]
|
||||||
|
)
|
||||||
|
# + backup_dict['switch_list'])+backup_dict['sqlserver_list']
|
||||||
|
|
||||||
|
|
||||||
@app.route('/json')
|
@app.route("/json")
|
||||||
|
@auth.require_auth
|
||||||
def backup_json():
|
def backup_json():
|
||||||
backup_dict = read_config()
|
backup_dict = read_config()
|
||||||
return json.dumps(backup_dict['rsync_list']+backup_dict['rsync_btrfs_list']+backup_dict['rsync_ssh_list']+backup_dict['pgsql_list']+backup_dict['mysql_list']+backup_dict['xva_list']+backup_dict['null_list']+backup_dict['metadata_list'])
|
return json.dumps(
|
||||||
#+ backup_dict['switch_list'])+backup_dict['sqlserver_list']
|
backup_dict["rsync_list"]
|
||||||
|
+ backup_dict["rsync_btrfs_list"]
|
||||||
|
+ backup_dict["rsync_ssh_list"]
|
||||||
|
+ backup_dict["pgsql_list"]
|
||||||
|
+ backup_dict["mysql_list"]
|
||||||
|
+ backup_dict["xva_list"]
|
||||||
|
+ backup_dict["null_list"]
|
||||||
|
+ backup_dict["metadata_list"]
|
||||||
|
)
|
||||||
|
# + backup_dict['switch_list'])+backup_dict['sqlserver_list']
|
||||||
|
|
||||||
|
|
||||||
def check_usb_disk():
|
def check_usb_disk():
|
||||||
"""This method returns the mounts point of FIRST external disk"""
|
"""This method returns the mounts point of FIRST external disk"""
|
||||||
# disk_name = []
|
|
||||||
usb_disk_list = []
|
usb_disk_list = []
|
||||||
for name in glob.glob('/dev/sd[a-z]'):
|
for name in glob.glob("/dev/sd[a-z]"):
|
||||||
for line in os.popen("udevadm info -q env -n %s" % name):
|
# Validate device name to prevent command injection
|
||||||
if re.match("ID_PATH=.*usb.*", line):
|
if not re.match(r"^/dev/sd[a-z]$", name):
|
||||||
usb_disk_list += [ name ]
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["udevadm", "info", "-q", "env", "-n", name],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
if re.match("ID_PATH=.*usb.*", line):
|
||||||
|
usb_disk_list.append(name)
|
||||||
|
break
|
||||||
|
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
||||||
|
continue
|
||||||
|
|
||||||
if len(usb_disk_list) == 0:
|
if len(usb_disk_list) == 0:
|
||||||
raise_error("Cannot find any external usb disk", "You should plug the usb hard drive into the server")
|
raise_error("Cannot find any external usb disk", "You should plug the usb hard drive into the server")
|
||||||
@@ -296,154 +408,227 @@ def check_usb_disk():
|
|||||||
|
|
||||||
usb_partition_list = []
|
usb_partition_list = []
|
||||||
for usb_disk in usb_disk_list:
|
for usb_disk in usb_disk_list:
|
||||||
cmd = "udevadm info -q path -n %s" % usb_disk + '1'
|
partition = usb_disk + "1"
|
||||||
output = os.popen(cmd).read()
|
# Validate partition name
|
||||||
print("cmd : " + cmd)
|
if not re.match(r"^/dev/sd[a-z]1$", partition):
|
||||||
print("output : " + output)
|
continue
|
||||||
|
|
||||||
if '/devices/pci' in output:
|
try:
|
||||||
#flash("partition found: %s1" % usb_disk)
|
result = subprocess.run(
|
||||||
usb_partition_list.append(usb_disk + "1")
|
["udevadm", "info", "-q", "path", "-n", partition],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
output = result.stdout
|
||||||
|
print("partition check: " + partition)
|
||||||
|
print("output : " + output)
|
||||||
|
|
||||||
|
if "/devices/pci" in output:
|
||||||
|
usb_partition_list.append(partition)
|
||||||
|
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
||||||
|
continue
|
||||||
|
|
||||||
print(usb_partition_list)
|
print(usb_partition_list)
|
||||||
|
|
||||||
if len(usb_partition_list) ==0:
|
if len(usb_partition_list) == 0:
|
||||||
raise_error("The drive %s has no partition" % (usb_disk_list[0] ), "You should initialize the usb drive and format an ext4 partition with TISBACKUP label")
|
raise_error(
|
||||||
|
"The drive %s has no partition" % (usb_disk_list[0]),
|
||||||
|
"You should initialize the usb drive and format an ext4 partition with TISBACKUP label",
|
||||||
|
)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
tisbackup_partition_list = []
|
tisbackup_partition_list = []
|
||||||
for usb_partition in usb_partition_list:
|
for usb_partition in usb_partition_list:
|
||||||
if "tisbackup" in os.popen("/sbin/dumpe2fs -h %s 2>&1 |/bin/grep 'volume name'" % usb_partition).read().lower():
|
# Validate partition name to prevent command injection
|
||||||
flash("tisbackup backup partition found: %s" % usb_partition)
|
if not re.match(r"^/dev/sd[a-z]1$", usb_partition):
|
||||||
tisbackup_partition_list.append(usb_partition)
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["/sbin/dumpe2fs", "-h", usb_partition],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
if "tisbackup" in result.stdout.lower():
|
||||||
|
flash("tisbackup backup partition found: %s" % usb_partition)
|
||||||
|
tisbackup_partition_list.append(usb_partition)
|
||||||
|
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
||||||
|
continue
|
||||||
|
|
||||||
print(tisbackup_partition_list)
|
print(tisbackup_partition_list)
|
||||||
|
|
||||||
if len(tisbackup_partition_list) ==0:
|
if len(tisbackup_partition_list) == 0:
|
||||||
raise_error("No tisbackup partition exist on disk %s" % (usb_disk_list[0] ), "You should initialize the usb drive and format an ext4 partition with TISBACKUP label")
|
raise_error(
|
||||||
|
"No tisbackup partition exist on disk %s" % (usb_disk_list[0]),
|
||||||
|
"You should initialize the usb drive and format an ext4 partition with TISBACKUP label",
|
||||||
|
)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
if len(tisbackup_partition_list) > 1:
|
if len(tisbackup_partition_list) > 1:
|
||||||
raise_error("There are many usb disk", "You should plug remove one of them")
|
raise_error("There are many usb disk", "You should plug remove one of them")
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
return tisbackup_partition_list[0]
|
return tisbackup_partition_list[0]
|
||||||
|
|
||||||
|
|
||||||
def check_already_mount(partition_name,refresh):
|
def check_already_mount(partition_name, refresh):
|
||||||
with open('/proc/mounts') as f:
|
# Validate partition name to prevent path traversal
|
||||||
|
if not re.match(r"^/dev/[a-z0-9]+$", partition_name):
|
||||||
|
raise_error("Invalid partition name", "Partition name contains invalid characters")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
with open("/proc/mounts") as f:
|
||||||
mount_point = ""
|
mount_point = ""
|
||||||
for line in f.readlines():
|
for line in f.readlines():
|
||||||
if line.startswith(partition_name):
|
if line.startswith(partition_name):
|
||||||
mount_point = line.split(' ')[1]
|
mount_point = line.split(" ")[1]
|
||||||
if not refresh:
|
if not refresh and mount_point:
|
||||||
run_command("/bin/umount %s" % mount_point)
|
try:
|
||||||
os.rmdir(mount_point)
|
subprocess.run(["/bin/umount", mount_point], check=True, timeout=30)
|
||||||
|
os.rmdir(mount_point)
|
||||||
|
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError) as e:
|
||||||
|
raise_error(f"Failed to unmount {mount_point}", str(e))
|
||||||
return mount_point
|
return mount_point
|
||||||
|
|
||||||
def run_command(cmd, info=""):
|
|
||||||
flash("Executing: %s"% cmd)
|
def run_command(cmd_list, info=""):
|
||||||
from subprocess import CalledProcessError, check_output
|
"""Execute a command safely using subprocess.run with list arguments.
|
||||||
result =""
|
|
||||||
try:
|
Args:
|
||||||
result = check_output(cmd, stderr=subprocess.STDOUT,shell=True)
|
cmd_list: List of command arguments (or string for backward compatibility)
|
||||||
except CalledProcessError as e:
|
info: Additional info message on error
|
||||||
raise_error(result,info)
|
"""
|
||||||
return result
|
# Handle legacy string commands by converting to list
|
||||||
|
if isinstance(cmd_list, str):
|
||||||
|
flash(f"Executing (legacy): {cmd_list}")
|
||||||
|
# This should be refactored - shell=True is unsafe
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd_list,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
shell=True,
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise_error(result.stderr or result.stdout, info)
|
||||||
|
return result.stdout
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
raise_error("Command timeout", info)
|
||||||
|
else:
|
||||||
|
flash(f"Executing: {' '.join(cmd_list)}")
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd_list,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
return result.stdout
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
raise_error(e.stderr or e.stdout, info)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
raise_error("Command timeout", info)
|
||||||
|
|
||||||
|
|
||||||
def check_mount_disk(partition_name, refresh):
|
def check_mount_disk(partition_name, refresh):
|
||||||
|
mount_point = check_already_mount(partition_name, refresh)
|
||||||
mount_point = check_already_mount(partition_name, refresh)
|
|
||||||
if not refresh:
|
if not refresh:
|
||||||
|
mount_point = "/mnt/TISBACKUP-" + str(time.time())
|
||||||
|
|
||||||
mount_point = "/mnt/TISBACKUP-" +str(time.time())
|
|
||||||
os.mkdir(mount_point)
|
os.mkdir(mount_point)
|
||||||
flash("must mount " + partition_name )
|
flash("must mount " + partition_name)
|
||||||
cmd = "mount %s %s" % (partition_name, mount_point)
|
cmd = "mount %s %s" % (partition_name, mount_point)
|
||||||
if run_command(cmd,"You should manualy mount the usb drive") != "":
|
if run_command(cmd, "You should manualy mount the usb drive") != "":
|
||||||
flash("Remove directory: %s" % mount_point)
|
flash("Remove directory: %s" % mount_point)
|
||||||
os.rmdir(mount_point)
|
os.rmdir(mount_point)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
return mount_point
|
return mount_point
|
||||||
|
|
||||||
@app.route('/status.json')
|
|
||||||
|
@app.route("/status.json")
|
||||||
|
@auth.require_auth
|
||||||
def export_backup_status():
|
def export_backup_status():
|
||||||
exports = dbstat.query('select * from stats where TYPE="EXPORT" and backup_start>="%s"' % mindate)
|
exports = dbstat.query('select * from stats where TYPE="EXPORT" and backup_start>="%s"' % mindate)
|
||||||
error = ""
|
error = ""
|
||||||
finish=not runnings_backups()
|
finish = not runnings_backups()
|
||||||
if get_task() != None and finish:
|
if get_task() is not None and finish:
|
||||||
status = get_task().get()
|
status = get_task().get()
|
||||||
if status != "ok":
|
if status != "ok":
|
||||||
error = "Export failing with error: "+status
|
error = "Export failing with error: " + status
|
||||||
|
|
||||||
|
return jsonify(data=exports, finish=finish, error=error)
|
||||||
|
|
||||||
return jsonify(data=exports,finish=finish,error=error)
|
|
||||||
|
|
||||||
def runnings_backups():
|
def runnings_backups():
|
||||||
task = get_task()
|
task = get_task()
|
||||||
is_runnig = (task != None)
|
is_runnig = task is not None
|
||||||
finish = ( is_runnig and task.get() != None)
|
finish = is_runnig and task.get() is not None
|
||||||
return is_runnig and not finish
|
return is_runnig and not finish
|
||||||
|
|
||||||
|
|
||||||
@app.route('/backups.json')
|
@app.route("/backups.json")
|
||||||
|
@auth.require_auth
|
||||||
def last_backup_json():
|
def last_backup_json():
|
||||||
exports = dbstat.query('select * from stats where TYPE="BACKUP" ORDER BY backup_start DESC ')
|
exports = dbstat.query('select * from stats where TYPE="BACKUP" ORDER BY backup_start DESC ')
|
||||||
return Response(response=json.dumps(exports),
|
return Response(response=json.dumps(exports), status=200, mimetype="application/json")
|
||||||
status=200,
|
|
||||||
mimetype="application/json")
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/last_backups')
|
@app.route("/last_backups")
|
||||||
|
@auth.require_auth
|
||||||
def last_backup():
|
def last_backup():
|
||||||
exports = dbstat.query('select * from stats where TYPE="BACKUP" ORDER BY backup_start DESC LIMIT 20 ')
|
exports = dbstat.query('select * from stats where TYPE="BACKUP" ORDER BY backup_start DESC LIMIT 20 ')
|
||||||
return render_template("last_backups.html", backups=exports)
|
return render_template("last_backups.html", backups=exports)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/export_backup')
|
@app.route("/export_backup")
|
||||||
|
@auth.require_auth
|
||||||
def export_backup():
|
def export_backup():
|
||||||
|
|
||||||
raise_error("", "")
|
raise_error("", "")
|
||||||
backup_dict = read_config()
|
backup_dict = read_config()
|
||||||
sections = []
|
sections = []
|
||||||
backup_sections = []
|
backup_sections = []
|
||||||
for backup_types in backup_dict:
|
for backup_types in backup_dict:
|
||||||
if backup_types == "null_list":
|
if backup_types == "null_list":
|
||||||
continue
|
continue
|
||||||
for section in backup_dict[backup_types]:
|
for section in backup_dict[backup_types]:
|
||||||
#if section.count > 0:
|
# if section.count > 0:
|
||||||
if len(section) > 0:
|
if len(section) > 0:
|
||||||
sections.append(section[1])
|
sections.append(section[1])
|
||||||
|
|
||||||
noJobs = (not runnings_backups())
|
noJobs = not runnings_backups()
|
||||||
if "start" in list(request.args.keys()) or not noJobs:
|
if "start" in list(request.args.keys()) or not noJobs:
|
||||||
start=True
|
start = True
|
||||||
if "sections" in list(request.args.keys()):
|
if "sections" in list(request.args.keys()):
|
||||||
backup_sections = request.args.getlist('sections')
|
backup_sections = request.args.getlist("sections")
|
||||||
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
start=False
|
start = False
|
||||||
cp.read(tisbackup_config_file)
|
cp.read(tisbackup_config_file)
|
||||||
|
|
||||||
partition_name = check_usb_disk()
|
partition_name = check_usb_disk()
|
||||||
if partition_name:
|
if partition_name:
|
||||||
if noJobs:
|
if noJobs:
|
||||||
mount_point = check_mount_disk( partition_name, False)
|
mount_point = check_mount_disk(partition_name, False)
|
||||||
else:
|
else:
|
||||||
mount_point = check_mount_disk( partition_name, True)
|
mount_point = check_mount_disk(partition_name, True)
|
||||||
if noJobs:
|
if noJobs:
|
||||||
global mindate
|
global mindate
|
||||||
mindate = datetime2isodate(datetime.datetime.now())
|
mindate = datetime2isodate(datetime.datetime.now())
|
||||||
if not error and start:
|
if not error and start:
|
||||||
print(tisbackup_config_file)
|
print(tisbackup_config_file)
|
||||||
task = run_export_backup(base=backup_base_dir, config_file=CONFIG[config_number], mount_point=mount_point, backup_sections=",".join([str(x) for x in backup_sections]))
|
task = run_export_backup(
|
||||||
|
base=backup_base_dir,
|
||||||
|
config_file=CONFIG[config_number],
|
||||||
|
mount_point=mount_point,
|
||||||
|
backup_sections=",".join([str(x) for x in backup_sections]),
|
||||||
|
)
|
||||||
set_task(task)
|
set_task(task)
|
||||||
|
|
||||||
|
|
||||||
return render_template("export_backup.html", error=error, start=start, info=info, email=ADMIN_EMAIL, sections=sections)
|
return render_template("export_backup.html", error=error, start=start, info=info, email=ADMIN_EMAIL, sections=sections)
|
||||||
|
|
||||||
|
|
||||||
@@ -456,6 +641,7 @@ def raise_error(strError, strInfo):
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
read_config()
|
read_config()
|
||||||
from os import environ
|
from os import environ
|
||||||
if 'WINGDB_ACTIVE' in environ:
|
|
||||||
|
if "WINGDB_ACTIVE" in environ:
|
||||||
app.debug = False
|
app.debug = False
|
||||||
app.run(host= '0.0.0.0',port=8080)
|
app.run(host="0.0.0.0", port=8080)
|
||||||
|
|||||||
@@ -0,0 +1,951 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 2
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alabaster"
|
||||||
|
version = "0.7.16"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c9/3e/13dd8e5ed9094e734ac430b5d0eb4f2bb001708a8b7856cbf8e084e001ba/alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65", size = 23776, upload-time = "2024-01-10T00:56:10.189Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/34/d4e1c02d3bee589efb5dfa17f88ea08bdb3e3eac12bc475462aec52ed223/alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92", size = 13511, upload-time = "2024-01-10T00:56:08.388Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "astroid"
|
||||||
|
version = "3.3.11"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/18/74/dfb75f9ccd592bbedb175d4a32fc643cf569d7c218508bfbd6ea7ef9c091/astroid-3.3.11.tar.gz", hash = "sha256:1e5a5011af2920c7c67a53f65d536d65bfa7116feeaf2354d8b94f29573bb0ce", size = 400439, upload-time = "2025-07-13T18:04:23.177Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/af/0f/3b8fdc946b4d9cc8cc1e8af42c4e409468c84441b933d037e101b3d72d86/astroid-3.3.11-py3-none-any.whl", hash = "sha256:54c760ae8322ece1abd213057c4b5bba7c49818853fc901ef09719a60dbf9dec", size = 275612, upload-time = "2025-07-13T18:04:21.07Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "authlib"
|
||||||
|
version = "1.6.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "cryptography" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cd/3f/1d3bbd0bf23bdd99276d4def22f29c27a914067b4cf66f753ff9b8bbd0f3/authlib-1.6.5.tar.gz", hash = "sha256:6aaf9c79b7cc96c900f0b284061691c5d4e61221640a948fe690b556a6d6d10b", size = 164553, upload-time = "2025-10-02T13:36:09.489Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/aa/5082412d1ee302e9e7d80b6949bc4d2a8fa1149aaab610c5fc24709605d6/authlib-1.6.5-py2.py3-none-any.whl", hash = "sha256:3e0e0507807f842b02175507bdee8957a1d5707fd4afb17c32fb43fee90b6e3a", size = 243608, upload-time = "2025-10-02T13:36:07.637Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "babel"
|
||||||
|
version = "2.17.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bcrypt"
|
||||||
|
version = "4.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/bb/5d/6d7433e0f3cd46ce0b43cd65e1db465ea024dbb8216fb2404e919c2ad77b/bcrypt-4.3.0.tar.gz", hash = "sha256:3a3fd2204178b6d2adcf09cb4f6426ffef54762577a7c9b54c159008cb288c18", size = 25697, upload-time = "2025-02-28T01:24:09.174Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/2c/3d44e853d1fe969d229bd58d39ae6902b3d924af0e2b5a60d17d4b809ded/bcrypt-4.3.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f01e060f14b6b57bbb72fc5b4a83ac21c443c9a2ee708e04a10e9192f90a6281", size = 483719, upload-time = "2025-02-28T01:22:34.539Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/e2/58ff6e2a22eca2e2cff5370ae56dba29d70b1ea6fc08ee9115c3ae367795/bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5eeac541cefd0bb887a371ef73c62c3cd78535e4887b310626036a7c0a817bb", size = 272001, upload-time = "2025-02-28T01:22:38.078Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/1f/c55ed8dbe994b1d088309e366749633c9eb90d139af3c0a50c102ba68a1a/bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59e1aa0e2cd871b08ca146ed08445038f42ff75968c7ae50d2fdd7860ade2180", size = 277451, upload-time = "2025-02-28T01:22:40.787Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/1c/794feb2ecf22fe73dcfb697ea7057f632061faceb7dcf0f155f3443b4d79/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:0042b2e342e9ae3d2ed22727c1262f76cc4f345683b5c1715f0250cf4277294f", size = 272792, upload-time = "2025-02-28T01:22:43.144Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/13/b7/0b289506a3f3598c2ae2bdfa0ea66969812ed200264e3f61df77753eee6d/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74a8d21a09f5e025a9a23e7c0fd2c7fe8e7503e4d356c0a2c1486ba010619f09", size = 289752, upload-time = "2025-02-28T01:22:45.56Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/24/d0fb023788afe9e83cc118895a9f6c57e1044e7e1672f045e46733421fe6/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:0142b2cb84a009f8452c8c5a33ace5e3dfec4159e7735f5afe9a4d50a8ea722d", size = 277762, upload-time = "2025-02-28T01:22:47.023Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/38/cde58089492e55ac4ef6c49fea7027600c84fd23f7520c62118c03b4625e/bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:12fa6ce40cde3f0b899729dbd7d5e8811cb892d31b6f7d0334a1f37748b789fd", size = 272384, upload-time = "2025-02-28T01:22:49.221Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/6a/d5026520843490cfc8135d03012a413e4532a400e471e6188b01b2de853f/bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:5bd3cca1f2aa5dbcf39e2aa13dd094ea181f48959e1071265de49cc2b82525af", size = 277329, upload-time = "2025-02-28T01:22:51.603Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/a3/4fc5255e60486466c389e28c12579d2829b28a527360e9430b4041df4cf9/bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:335a420cfd63fc5bc27308e929bee231c15c85cc4c496610ffb17923abf7f231", size = 305241, upload-time = "2025-02-28T01:22:53.283Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/15/2b37bc07d6ce27cc94e5b10fd5058900eb8fb11642300e932c8c82e25c4a/bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:0e30e5e67aed0187a1764911af023043b4542e70a7461ad20e837e94d23e1d6c", size = 309617, upload-time = "2025-02-28T01:22:55.461Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/1f/99f65edb09e6c935232ba0430c8c13bb98cb3194b6d636e61d93fe60ac59/bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b8d62290ebefd49ee0b3ce7500f5dbdcf13b81402c05f6dafab9a1e1b27212f", size = 335751, upload-time = "2025-02-28T01:22:57.81Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/1b/b324030c706711c99769988fcb694b3cb23f247ad39a7823a78e361bdbb8/bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef6630e0ec01376f59a006dc72918b1bf436c3b571b80fa1968d775fa02fe7d", size = 355965, upload-time = "2025-02-28T01:22:59.181Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/dd/20372a0579dd915dfc3b1cd4943b3bca431866fcb1dfdfd7518c3caddea6/bcrypt-4.3.0-cp313-cp313t-win32.whl", hash = "sha256:7a4be4cbf241afee43f1c3969b9103a41b40bcb3a3f467ab19f891d9bc4642e4", size = 155316, upload-time = "2025-02-28T01:23:00.763Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/52/45d969fcff6b5577c2bf17098dc36269b4c02197d551371c023130c0f890/bcrypt-4.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c1949bf259a388863ced887c7861da1df681cb2388645766c89fdfd9004c669", size = 147752, upload-time = "2025-02-28T01:23:02.908Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/22/5ada0b9af72b60cbc4c9a399fdde4af0feaa609d27eb0adc61607997a3fa/bcrypt-4.3.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:f81b0ed2639568bf14749112298f9e4e2b28853dab50a8b357e31798686a036d", size = 498019, upload-time = "2025-02-28T01:23:05.838Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/8c/252a1edc598dc1ce57905be173328eda073083826955ee3c97c7ff5ba584/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:864f8f19adbe13b7de11ba15d85d4a428c7e2f344bac110f667676a0ff84924b", size = 279174, upload-time = "2025-02-28T01:23:07.274Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/5b/4547d5c49b85f0337c13929f2ccbe08b7283069eea3550a457914fc078aa/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e36506d001e93bffe59754397572f21bb5dc7c83f54454c990c74a468cd589e", size = 283870, upload-time = "2025-02-28T01:23:09.151Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/21/7dbaf3fa1745cb63f776bb046e481fbababd7d344c5324eab47f5ca92dd2/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:842d08d75d9fe9fb94b18b071090220697f9f184d4547179b60734846461ed59", size = 279601, upload-time = "2025-02-28T01:23:11.461Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/64/e042fc8262e971347d9230d9abbe70d68b0a549acd8611c83cebd3eaec67/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7c03296b85cb87db865d91da79bf63d5609284fc0cab9472fdd8367bbd830753", size = 297660, upload-time = "2025-02-28T01:23:12.989Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/b8/6294eb84a3fef3b67c69b4470fcdd5326676806bf2519cda79331ab3c3a9/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:62f26585e8b219cdc909b6a0069efc5e4267e25d4a3770a364ac58024f62a761", size = 284083, upload-time = "2025-02-28T01:23:14.5Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/e6/baff635a4f2c42e8788fe1b1633911c38551ecca9a749d1052d296329da6/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:beeefe437218a65322fbd0069eb437e7c98137e08f22c4660ac2dc795c31f8bb", size = 279237, upload-time = "2025-02-28T01:23:16.686Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/39/48/46f623f1b0c7dc2e5de0b8af5e6f5ac4cc26408ac33f3d424e5ad8da4a90/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:97eea7408db3a5bcce4a55d13245ab3fa566e23b4c67cd227062bb49e26c585d", size = 283737, upload-time = "2025-02-28T01:23:18.897Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/8b/70671c3ce9c0fca4a6cc3cc6ccbaa7e948875a2e62cbd146e04a4011899c/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:191354ebfe305e84f344c5964c7cd5f924a3bfc5d405c75ad07f232b6dffb49f", size = 312741, upload-time = "2025-02-28T01:23:21.041Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/fb/910d3a1caa2d249b6040a5caf9f9866c52114d51523ac2fb47578a27faee/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:41261d64150858eeb5ff43c753c4b216991e0ae16614a308a15d909503617732", size = 316472, upload-time = "2025-02-28T01:23:23.183Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/cf/7cf3a05b66ce466cfb575dbbda39718d45a609daa78500f57fa9f36fa3c0/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:33752b1ba962ee793fa2b6321404bf20011fe45b9afd2a842139de3011898fef", size = 343606, upload-time = "2025-02-28T01:23:25.361Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/b8/e970ecc6d7e355c0d892b7f733480f4aa8509f99b33e71550242cf0b7e63/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:50e6e80a4bfd23a25f5c05b90167c19030cf9f87930f7cb2eacb99f45d1c3304", size = 362867, upload-time = "2025-02-28T01:23:26.875Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/97/8d3118efd8354c555a3422d544163f40d9f236be5b96c714086463f11699/bcrypt-4.3.0-cp38-abi3-win32.whl", hash = "sha256:67a561c4d9fb9465ec866177e7aebcad08fe23aaf6fbd692a6fab69088abfc51", size = 160589, upload-time = "2025-02-28T01:23:28.381Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/07/416f0b99f7f3997c69815365babbc2e8754181a4b1899d921b3c7d5b6f12/bcrypt-4.3.0-cp38-abi3-win_amd64.whl", hash = "sha256:584027857bc2843772114717a7490a37f68da563b3620f78a849bcb54dc11e62", size = 152794, upload-time = "2025-02-28T01:23:30.187Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/c1/3fa0e9e4e0bfd3fd77eb8b52ec198fd6e1fd7e9402052e43f23483f956dd/bcrypt-4.3.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0d3efb1157edebfd9128e4e46e2ac1a64e0c1fe46fb023158a407c7892b0f8c3", size = 498969, upload-time = "2025-02-28T01:23:31.945Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/d4/755ce19b6743394787fbd7dff6bf271b27ee9b5912a97242e3caf125885b/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08bacc884fd302b611226c01014eca277d48f0a05187666bca23aac0dad6fe24", size = 279158, upload-time = "2025-02-28T01:23:34.161Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/5d/805ef1a749c965c46b28285dfb5cd272a7ed9fa971f970435a5133250182/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6746e6fec103fcd509b96bacdfdaa2fbde9a553245dbada284435173a6f1aef", size = 284285, upload-time = "2025-02-28T01:23:35.765Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ab/2b/698580547a4a4988e415721b71eb45e80c879f0fb04a62da131f45987b96/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:afe327968aaf13fc143a56a3360cb27d4ad0345e34da12c7290f1b00b8fe9a8b", size = 279583, upload-time = "2025-02-28T01:23:38.021Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/87/62e1e426418204db520f955ffd06f1efd389feca893dad7095bf35612eec/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d9af79d322e735b1fc33404b5765108ae0ff232d4b54666d46730f8ac1a43676", size = 297896, upload-time = "2025-02-28T01:23:39.575Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/c6/8fedca4c2ada1b6e889c52d2943b2f968d3427e5d65f595620ec4c06fa2f/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f1e3ffa1365e8702dc48c8b360fef8d7afeca482809c5e45e653af82ccd088c1", size = 284492, upload-time = "2025-02-28T01:23:40.901Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/4d/c43332dcaaddb7710a8ff5269fcccba97ed3c85987ddaa808db084267b9a/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3004df1b323d10021fda07a813fd33e0fd57bef0e9a480bb143877f6cba996fe", size = 279213, upload-time = "2025-02-28T01:23:42.653Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/7f/1e36379e169a7df3a14a1c160a49b7b918600a6008de43ff20d479e6f4b5/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:531457e5c839d8caea9b589a1bcfe3756b0547d7814e9ce3d437f17da75c32b0", size = 284162, upload-time = "2025-02-28T01:23:43.964Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/0a/644b2731194b0d7646f3210dc4d80c7fee3ecb3a1f791a6e0ae6bb8684e3/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:17a854d9a7a476a89dcef6c8bd119ad23e0f82557afbd2c442777a16408e614f", size = 312856, upload-time = "2025-02-28T01:23:46.011Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/62/2a871837c0bb6ab0c9a88bf54de0fc021a6a08832d4ea313ed92a669d437/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6fb1fd3ab08c0cbc6826a2e0447610c6f09e983a281b919ed721ad32236b8b23", size = 316726, upload-time = "2025-02-28T01:23:47.575Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/a1/9898ea3faac0b156d457fd73a3cb9c2855c6fd063e44b8522925cdd8ce46/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e965a9c1e9a393b8005031ff52583cedc15b7884fce7deb8b0346388837d6cfe", size = 343664, upload-time = "2025-02-28T01:23:49.059Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/f2/71b4ed65ce38982ecdda0ff20c3ad1b15e71949c78b2c053df53629ce940/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:79e70b8342a33b52b55d93b3a59223a844962bef479f6a0ea318ebbcadf71505", size = 363128, upload-time = "2025-02-28T01:23:50.399Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/99/12f6a58eca6dea4be992d6c681b7ec9410a1d9f5cf368c61437e31daa879/bcrypt-4.3.0-cp39-abi3-win32.whl", hash = "sha256:b4d4e57f0a63fd0b358eb765063ff661328f69a04494427265950c71b992a39a", size = 160598, upload-time = "2025-02-28T01:23:51.775Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/cf/45fb5261ece3e6b9817d3d82b2f343a505fd58674a92577923bc500bd1aa/bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b", size = 152799, upload-time = "2025-02-28T01:23:53.139Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blinker"
|
||||||
|
version = "1.9.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "certifi"
|
||||||
|
version = "2025.1.31"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577, upload-time = "2025-01-31T02:16:47.166Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393, upload-time = "2025-01-31T02:16:45.015Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cffi"
|
||||||
|
version = "1.17.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pycparser" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload-time = "2024-09-04T20:44:28.956Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload-time = "2024-09-04T20:44:30.289Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload-time = "2024-09-04T20:44:35.191Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload-time = "2024-09-04T20:44:36.743Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload-time = "2024-09-04T20:44:43.733Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "charset-normalizer"
|
||||||
|
version = "3.4.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188, upload-time = "2024-12-24T18:12:35.43Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698, upload-time = "2024-12-24T18:11:05.834Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162, upload-time = "2024-12-24T18:11:07.064Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263, upload-time = "2024-12-24T18:11:08.374Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966, upload-time = "2024-12-24T18:11:09.831Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992, upload-time = "2024-12-24T18:11:12.03Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162, upload-time = "2024-12-24T18:11:13.372Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972, upload-time = "2024-12-24T18:11:14.628Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095, upload-time = "2024-12-24T18:11:17.672Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668, upload-time = "2024-12-24T18:11:18.989Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073, upload-time = "2024-12-24T18:11:21.507Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732, upload-time = "2024-12-24T18:11:22.774Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391, upload-time = "2024-12-24T18:11:24.139Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702, upload-time = "2024-12-24T18:11:26.535Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767, upload-time = "2024-12-24T18:12:32.852Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "click"
|
||||||
|
version = "8.1.8"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "coverage"
|
||||||
|
version = "7.10.7"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320, upload-time = "2025-09-21T20:01:56.629Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575, upload-time = "2025-09-21T20:01:58.203Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568, upload-time = "2025-09-21T20:01:59.748Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a2/77/8c6d22bf61921a59bce5471c2f1f7ac30cd4ac50aadde72b8c48d5727902/coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b", size = 252174, upload-time = "2025-09-21T20:02:01.192Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/20/b6ea4f69bbb52dac0aebd62157ba6a9dddbfe664f5af8122dac296c3ee15/coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49", size = 253447, upload-time = "2025-09-21T20:02:02.701Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/28/4831523ba483a7f90f7b259d2018fef02cb4d5b90bc7c1505d6e5a84883c/coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911", size = 249779, upload-time = "2025-09-21T20:02:04.185Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/9f/4331142bc98c10ca6436d2d620c3e165f31e6c58d43479985afce6f3191c/coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0", size = 251604, upload-time = "2025-09-21T20:02:06.034Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/60/bda83b96602036b77ecf34e6393a3836365481b69f7ed7079ab85048202b/coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f", size = 249497, upload-time = "2025-09-21T20:02:07.619Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/af/152633ff35b2af63977edd835d8e6430f0caef27d171edf2fc76c270ef31/coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c", size = 249350, upload-time = "2025-09-21T20:02:10.34Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/71/d92105d122bd21cebba877228990e1646d862e34a98bb3374d3fece5a794/coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f", size = 251111, upload-time = "2025-09-21T20:02:12.122Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746, upload-time = "2025-09-21T20:02:13.919Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541, upload-time = "2025-09-21T20:02:15.57Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170, upload-time = "2025-09-21T20:02:17.395Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/22/e04514bf2a735d8b0add31d2b4ab636fc02370730787c576bb995390d2d5/coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c", size = 219029, upload-time = "2025-09-21T20:02:18.936Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/0b/91128e099035ece15da3445d9015e4b4153a6059403452d324cbb0a575fa/coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15", size = 219259, upload-time = "2025-09-21T20:02:20.44Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/51/66420081e72801536a091a0c8f8c1f88a5c4bf7b9b1bdc6222c7afe6dc9b/coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4", size = 260592, upload-time = "2025-09-21T20:02:22.313Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/22/9b8d458c2881b22df3db5bb3e7369e63d527d986decb6c11a591ba2364f7/coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0", size = 262768, upload-time = "2025-09-21T20:02:24.287Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/08/16bee2c433e60913c610ea200b276e8eeef084b0d200bdcff69920bd5828/coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0", size = 264995, upload-time = "2025-09-21T20:02:26.133Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/9d/e53eb9771d154859b084b90201e5221bca7674ba449a17c101a5031d4054/coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65", size = 259546, upload-time = "2025-09-21T20:02:27.716Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ad/b0/69bc7050f8d4e56a89fb550a1577d5d0d1db2278106f6f626464067b3817/coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541", size = 262544, upload-time = "2025-09-21T20:02:29.216Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/4b/2514b060dbd1bc0aaf23b852c14bb5818f244c664cb16517feff6bb3a5ab/coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6", size = 260308, upload-time = "2025-09-21T20:02:31.226Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/78/7ba2175007c246d75e496f64c06e94122bdb914790a1285d627a918bd271/coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999", size = 258920, upload-time = "2025-09-21T20:02:32.823Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/b3/fac9f7abbc841409b9a410309d73bfa6cfb2e51c3fada738cb607ce174f8/coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2", size = 261434, upload-time = "2025-09-21T20:02:34.86Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403, upload-time = "2025-09-21T20:02:37.034Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469, upload-time = "2025-09-21T20:02:39.011Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731, upload-time = "2025-09-21T20:02:40.939Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/9c/5844ab4ca6a4dd97a1850e030a15ec7d292b5c5cb93082979225126e35dd/coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520", size = 218302, upload-time = "2025-09-21T20:02:42.527Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/89/673f6514b0961d1f0e20ddc242e9342f6da21eaba3489901b565c0689f34/coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32", size = 218578, upload-time = "2025-09-21T20:02:44.468Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/e8/261cae479e85232828fb17ad536765c88dd818c8470aca690b0ac6feeaa3/coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f", size = 249629, upload-time = "2025-09-21T20:02:46.503Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/62/14ed6546d0207e6eda876434e3e8475a3e9adbe32110ce896c9e0c06bb9a/coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a", size = 252162, upload-time = "2025-09-21T20:02:48.689Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/49/07f00db9ac6478e4358165a08fb41b469a1b053212e8a00cb02f0d27a05f/coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360", size = 253517, upload-time = "2025-09-21T20:02:50.31Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a2/59/c5201c62dbf165dfbc91460f6dbbaa85a8b82cfa6131ac45d6c1bfb52deb/coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69", size = 249632, upload-time = "2025-09-21T20:02:51.971Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/ae/5920097195291a51fb00b3a70b9bbd2edbfe3c84876a1762bd1ef1565ebc/coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14", size = 251520, upload-time = "2025-09-21T20:02:53.858Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/3c/a815dde77a2981f5743a60b63df31cb322c944843e57dbd579326625a413/coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe", size = 249455, upload-time = "2025-09-21T20:02:55.807Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/99/f5cdd8421ea656abefb6c0ce92556709db2265c41e8f9fc6c8ae0f7824c9/coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e", size = 249287, upload-time = "2025-09-21T20:02:57.784Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/7a/e9a2da6a1fc5d007dd51fca083a663ab930a8c4d149c087732a5dbaa0029/coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd", size = 250946, upload-time = "2025-09-21T20:02:59.431Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009, upload-time = "2025-09-21T20:03:01.324Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804, upload-time = "2025-09-21T20:03:03.4Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384, upload-time = "2025-09-21T20:03:05.111Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/09/9a5608d319fa3eba7a2019addeacb8c746fb50872b57a724c9f79f146969/coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63", size = 219047, upload-time = "2025-09-21T20:03:06.795Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/6f/f58d46f33db9f2e3647b2d0764704548c184e6f5e014bef528b7f979ef84/coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2", size = 219266, upload-time = "2025-09-21T20:03:08.495Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/5c/183ffc817ba68e0b443b8c934c8795553eb0c14573813415bd59941ee165/coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d", size = 260767, upload-time = "2025-09-21T20:03:10.172Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/48/71a8abe9c1ad7e97548835e3cc1adbf361e743e9d60310c5f75c9e7bf847/coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0", size = 262931, upload-time = "2025-09-21T20:03:11.861Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/fd/193a8fb132acfc0a901f72020e54be5e48021e1575bb327d8ee1097a28fd/coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699", size = 265186, upload-time = "2025-09-21T20:03:13.539Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/8f/74ecc30607dd95ad50e3034221113ccb1c6d4e8085cc761134782995daae/coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9", size = 259470, upload-time = "2025-09-21T20:03:15.584Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/55/79ff53a769f20d71b07023ea115c9167c0bb56f281320520cf64c5298a96/coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f", size = 262626, upload-time = "2025-09-21T20:03:17.673Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/e2/dac66c140009b61ac3fc13af673a574b00c16efdf04f9b5c740703e953c0/coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1", size = 260386, upload-time = "2025-09-21T20:03:19.36Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a2/f1/f48f645e3f33bb9ca8a496bc4a9671b52f2f353146233ebd7c1df6160440/coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0", size = 258852, upload-time = "2025-09-21T20:03:21.007Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/3b/8442618972c51a7affeead957995cfa8323c0c9bcf8fa5a027421f720ff4/coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399", size = 261534, upload-time = "2025-09-21T20:03:23.12Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784, upload-time = "2025-09-21T20:03:24.769Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905, upload-time = "2025-09-21T20:03:26.93Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922, upload-time = "2025-09-21T20:03:28.672Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cryptography"
|
||||||
|
version = "44.0.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cd/25/4ce80c78963834b8a9fd1cc1266be5ed8d1840785c0f2e1b73b8d128d505/cryptography-44.0.2.tar.gz", hash = "sha256:c63454aa261a0cf0c5b4718349629793e9e634993538db841165b3df74f37ec0", size = 710807, upload-time = "2025-03-02T00:01:37.692Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/ef/83e632cfa801b221570c5f58c0369db6fa6cef7d9ff859feab1aae1a8a0f/cryptography-44.0.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:efcfe97d1b3c79e486554efddeb8f6f53a4cdd4cf6086642784fa31fc384e1d7", size = 6676361, upload-time = "2025-03-02T00:00:06.528Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/ec/7ea7c1e4c8fc8329506b46c6c4a52e2f20318425d48e0fe597977c71dbce/cryptography-44.0.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29ecec49f3ba3f3849362854b7253a9f59799e3763b0c9d0826259a88efa02f1", size = 3952350, upload-time = "2025-03-02T00:00:09.537Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/61/72e3afdb3c5ac510330feba4fc1faa0fe62e070592d6ad00c40bb69165e5/cryptography-44.0.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc821e161ae88bfe8088d11bb39caf2916562e0a2dc7b6d56714a48b784ef0bb", size = 4166572, upload-time = "2025-03-02T00:00:12.03Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/e4/ba680f0b35ed4a07d87f9e98f3ebccb05091f3bf6b5a478b943253b3bbd5/cryptography-44.0.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3c00b6b757b32ce0f62c574b78b939afab9eecaf597c4d624caca4f9e71e7843", size = 3958124, upload-time = "2025-03-02T00:00:14.518Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/e8/44ae3e68c8b6d1cbc59040288056df2ad7f7f03bbcaca6b503c737ab8e73/cryptography-44.0.2-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7bdcd82189759aba3816d1f729ce42ffded1ac304c151d0a8e89b9996ab863d5", size = 3678122, upload-time = "2025-03-02T00:00:17.212Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/7b/664ea5e0d1eab511a10e480baf1c5d3e681c7d91718f60e149cec09edf01/cryptography-44.0.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4973da6ca3db4405c54cd0b26d328be54c7747e89e284fcff166132eb7bccc9c", size = 4191831, upload-time = "2025-03-02T00:00:19.696Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/07/79554a9c40eb11345e1861f46f845fa71c9e25bf66d132e123d9feb8e7f9/cryptography-44.0.2-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4e389622b6927d8133f314949a9812972711a111d577a5d1f4bee5e58736b80a", size = 3960583, upload-time = "2025-03-02T00:00:22.488Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/6d/858e356a49a4f0b591bd6789d821427de18432212e137290b6d8a817e9bf/cryptography-44.0.2-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f514ef4cd14bb6fb484b4a60203e912cfcb64f2ab139e88c2274511514bf7308", size = 4191753, upload-time = "2025-03-02T00:00:25.038Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/80/62df41ba4916067fa6b125aa8c14d7e9181773f0d5d0bd4dcef580d8b7c6/cryptography-44.0.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1bc312dfb7a6e5d66082c87c34c8a62176e684b6fe3d90fcfe1568de675e6688", size = 4079550, upload-time = "2025-03-02T00:00:26.929Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/cd/2558cc08f7b1bb40683f99ff4327f8dcfc7de3affc669e9065e14824511b/cryptography-44.0.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b721b8b4d948b218c88cb8c45a01793483821e709afe5f622861fc6182b20a7", size = 4298367, upload-time = "2025-03-02T00:00:28.735Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/59/94ccc74788945bc3bd4cf355d19867e8057ff5fdbcac781b1ff95b700fb1/cryptography-44.0.2-cp37-abi3-win32.whl", hash = "sha256:51e4de3af4ec3899d6d178a8c005226491c27c4ba84101bfb59c901e10ca9f79", size = 2772843, upload-time = "2025-03-02T00:00:30.592Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/2c/0d0bbaf61ba05acb32f0841853cfa33ebb7a9ab3d9ed8bb004bd39f2da6a/cryptography-44.0.2-cp37-abi3-win_amd64.whl", hash = "sha256:c505d61b6176aaf982c5717ce04e87da5abc9a36a5b39ac03905c4aafe8de7aa", size = 3209057, upload-time = "2025-03-02T00:00:33.393Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/be/7a26142e6d0f7683d8a382dd963745e65db895a79a280a30525ec92be890/cryptography-44.0.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8e0ddd63e6bf1161800592c71ac794d3fb8001f2caebe0966e77c5234fa9efc3", size = 6677789, upload-time = "2025-03-02T00:00:36.009Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/88/638865be7198a84a7713950b1db7343391c6066a20e614f8fa286eb178ed/cryptography-44.0.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81276f0ea79a208d961c433a947029e1a15948966658cf6710bbabb60fcc2639", size = 3951919, upload-time = "2025-03-02T00:00:38.581Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/fc/99fe639bcdf58561dfad1faa8a7369d1dc13f20acd78371bb97a01613585/cryptography-44.0.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a1e657c0f4ea2a23304ee3f964db058c9e9e635cc7019c4aa21c330755ef6fd", size = 4167812, upload-time = "2025-03-02T00:00:42.934Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/7b/aafe60210ec93d5d7f552592a28192e51d3c6b6be449e7fd0a91399b5d07/cryptography-44.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6210c05941994290f3f7f175a4a57dbbb2afd9273657614c506d5976db061181", size = 3958571, upload-time = "2025-03-02T00:00:46.026Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/32/051f7ce79ad5a6ef5e26a92b37f172ee2d6e1cce09931646eef8de1e9827/cryptography-44.0.2-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1c3572526997b36f245a96a2b1713bf79ce99b271bbcf084beb6b9b075f29ea", size = 3679832, upload-time = "2025-03-02T00:00:48.647Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/2b/999b2a1e1ba2206f2d3bca267d68f350beb2b048a41ea827e08ce7260098/cryptography-44.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b042d2a275c8cee83a4b7ae30c45a15e6a4baa65a179a0ec2d78ebb90e4f6699", size = 4193719, upload-time = "2025-03-02T00:00:51.397Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/72/97/430e56e39a1356e8e8f10f723211a0e256e11895ef1a135f30d7d40f2540/cryptography-44.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d03806036b4f89e3b13b6218fefea8d5312e450935b1a2d55f0524e2ed7c59d9", size = 3960852, upload-time = "2025-03-02T00:00:53.317Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/33/c1cf182c152e1d262cac56850939530c05ca6c8d149aa0dcee490b417e99/cryptography-44.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c7362add18b416b69d58c910caa217f980c5ef39b23a38a0880dfd87bdf8cd23", size = 4193906, upload-time = "2025-03-02T00:00:56.49Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/99/87cf26d4f125380dc674233971069bc28d19b07f7755b29861570e513650/cryptography-44.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8cadc6e3b5a1f144a039ea08a0bdb03a2a92e19c46be3285123d32029f40a922", size = 4081572, upload-time = "2025-03-02T00:00:59.995Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/9f/6a3e0391957cc0c5f84aef9fbdd763035f2b52e998a53f99345e3ac69312/cryptography-44.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f101b1f780f7fc613d040ca4bdf835c6ef3b00e9bd7125a4255ec574c7916e4", size = 4298631, upload-time = "2025-03-02T00:01:01.623Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/a5/5bc097adb4b6d22a24dea53c51f37e480aaec3465285c253098642696423/cryptography-44.0.2-cp39-abi3-win32.whl", hash = "sha256:3dc62975e31617badc19a906481deacdeb80b4bb454394b4098e3f2525a488c5", size = 2773792, upload-time = "2025-03-02T00:01:04.133Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/cf/1f7649b8b9a3543e042d3f348e398a061923ac05b507f3f4d95f11938aa9/cryptography-44.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:5f6f90b72d8ccadb9c6e311c775c8305381db88374c65fa1a68250aa8a9cb3a6", size = 3210957, upload-time = "2025-03-02T00:01:06.987Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dill"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/12/80/630b4b88364e9a8c8c5797f4602d0f76ef820909ee32f0bacb9f90654042/dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0", size = 186976, upload-time = "2025-04-16T00:41:48.867Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/3d/9373ad9c56321fdab5b41197068e1d8c25883b3fea29dd361f9b55116869/dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049", size = 119668, upload-time = "2025-04-16T00:41:47.671Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "docutils"
|
||||||
|
version = "0.21.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flask"
|
||||||
|
version = "3.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "blinker" },
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "itsdangerous" },
|
||||||
|
{ name = "jinja2" },
|
||||||
|
{ name = "werkzeug" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/89/50/dff6380f1c7f84135484e176e0cac8690af72fa90e932ad2a0a60e28c69b/flask-3.1.0.tar.gz", hash = "sha256:5f873c5184c897c8d9d1b05df1e3d01b14910ce69607a117bd3277098a5836ac", size = 680824, upload-time = "2024-11-13T18:24:38.127Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/af/47/93213ee66ef8fae3b93b3e29206f6b251e65c97bd91d8e1c5596ef15af0a/flask-3.1.0-py3-none-any.whl", hash = "sha256:d667207822eb83f1c4b50949b1623c8fc8d51f2341d65f72e1a1815397551136", size = 102979, upload-time = "2024-11-13T18:24:36.135Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flask-login"
|
||||||
|
version = "0.6.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "flask" },
|
||||||
|
{ name = "werkzeug" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c3/6e/2f4e13e373bb49e68c02c51ceadd22d172715a06716f9299d9df01b6ddb2/Flask-Login-0.6.3.tar.gz", hash = "sha256:5e23d14a607ef12806c699590b89d0f0e0d67baeec599d75947bf9c147330333", size = 48834, upload-time = "2023-10-30T14:53:21.151Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/f5/67e9cc5c2036f58115f9fe0f00d203cf6780c3ff8ae0e705e7a9d9e8ff9e/Flask_Login-0.6.3-py3-none-any.whl", hash = "sha256:849b25b82a436bf830a054e74214074af59097171562ab10bfa999e6b78aae5d", size = 17303, upload-time = "2023-10-30T14:53:19.636Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "huey"
|
||||||
|
version = "2.5.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/86/8c/2dfecf3705f5e522097b4e9fb6fb38e627a0340f8362cc05bd7a845e4279/huey-2.5.3.tar.gz", hash = "sha256:089fc72b97fd26a513f15b09925c56fad6abe4a699a1f0e902170b37e85163c7", size = 836918, upload-time = "2025-03-19T14:46:51.614Z" }
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "3.10"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "imagesize"
|
||||||
|
version = "1.4.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a7/84/62473fb57d61e31fef6e36d64a179c8781605429fd927b5dd608c997be31/imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a", size = 1280026, upload-time = "2022-07-01T12:21:05.687Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769, upload-time = "2022-07-01T12:21:02.467Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniparse"
|
||||||
|
version = "0.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "six" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/4c/9a/02beaf11fc9ea7829d3a9041536934cd03990e09c359724f99ee6bd2b41b/iniparse-0.5.tar.gz", hash = "sha256:932e5239d526e7acb504017bb707be67019ac428a6932368e6851691093aa842", size = 32233, upload-time = "2020-01-29T14:12:35.567Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/b0/4d357324948188e76154b332e119fb28e374c1ebe4d4f6bca729aaa44309/iniparse-0.5-py3-none-any.whl", hash = "sha256:db6ef1d8a02395448e0e7b17ac0aa28b8d338b632bbd1ffca08c02ddae32cf97", size = 24445, upload-time = "2020-01-29T14:12:34.068Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "invoke"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/42/127e6d792884ab860defc3f4d80a8f9812e48ace584ffc5a346de58cdc6c/invoke-2.2.0.tar.gz", hash = "sha256:ee6cbb101af1a859c7fe84f2a264c059020b0cb7fe3535f9424300ab568f6bd5", size = 299835, upload-time = "2023-07-12T18:05:17.998Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/66/7f8c48009c72d73bc6bbe6eb87ac838d6a526146f7dab14af671121eb379/invoke-2.2.0-py3-none-any.whl", hash = "sha256:6ea924cc53d4f78e3d98bc436b08069a03077e6f85ad1ddaa8a116d7dad15820", size = 160274, upload-time = "2023-07-12T18:05:16.294Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "isort"
|
||||||
|
version = "6.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1e/82/fa43935523efdfcce6abbae9da7f372b627b27142c3419fcf13bf5b0c397/isort-6.1.0.tar.gz", hash = "sha256:9b8f96a14cfee0677e78e941ff62f03769a06d412aabb9e2a90487b3b7e8d481", size = 824325, upload-time = "2025-10-01T16:26:45.027Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/cc/9b681a170efab4868a032631dea1e8446d8ec718a7f657b94d49d1a12643/isort-6.1.0-py3-none-any.whl", hash = "sha256:58d8927ecce74e5087aef019f778d4081a3b6c98f15a80ba35782ca8a2097784", size = 94329, upload-time = "2025-10-01T16:26:43.291Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itsdangerous"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jinja2"
|
||||||
|
version = "3.1.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markupsafe" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markupsafe"
|
||||||
|
version = "3.0.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mccabe"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "25.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "paramiko"
|
||||||
|
version = "4.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "bcrypt" },
|
||||||
|
{ name = "cryptography" },
|
||||||
|
{ name = "invoke" },
|
||||||
|
{ name = "pynacl" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1f/e7/81fdcbc7f190cdb058cffc9431587eb289833bdd633e2002455ca9bb13d4/paramiko-4.0.0.tar.gz", hash = "sha256:6a25f07b380cc9c9a88d2b920ad37167ac4667f8d9886ccebd8f90f654b5d69f", size = 1630743, upload-time = "2025-08-04T01:02:03.711Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/90/a744336f5af32c433bd09af7854599682a383b37cfd78f7de263de6ad6cb/paramiko-4.0.0-py3-none-any.whl", hash = "sha256:0e20e00ac666503bf0b4eda3b6d833465a2b7aff2e2b3d79a8bba5ef144ee3b9", size = 223932, upload-time = "2025-08-04T01:02:02.029Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "peewee"
|
||||||
|
version = "3.17.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/57/09/4393bd378e70b7fc3163ee83353cc27bb520010a5c2b3c924121e7e7e068/peewee-3.17.9.tar.gz", hash = "sha256:fe15cd001758e324c8e3ca8c8ed900e7397c2907291789e1efc383e66b9bc7a8", size = 3026085, upload-time = "2025-02-05T16:30:35.774Z" }
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pexpect"
|
||||||
|
version = "4.9.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "ptyprocess" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "platformdirs"
|
||||||
|
version = "4.4.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ptyprocess"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pycparser"
|
||||||
|
version = "2.22"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload-time = "2024-03-30T13:22:22.564Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.19.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pylint"
|
||||||
|
version = "3.3.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "astroid" },
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "dill" },
|
||||||
|
{ name = "isort" },
|
||||||
|
{ name = "mccabe" },
|
||||||
|
{ name = "platformdirs" },
|
||||||
|
{ name = "tomlkit" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/04/9d/81c84a312d1fa8133b0db0c76148542a98349298a01747ab122f9314b04e/pylint-3.3.9.tar.gz", hash = "sha256:d312737d7b25ccf6b01cc4ac629b5dcd14a0fcf3ec392735ac70f137a9d5f83a", size = 1525946, upload-time = "2025-10-05T18:41:43.786Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/a7/69460c4a6af7575449e615144aa2205b89408dc2969b87bc3df2f262ad0b/pylint-3.3.9-py3-none-any.whl", hash = "sha256:01f9b0462c7730f94786c283f3e52a1fbdf0494bbe0971a78d7277ef46a751e7", size = 523465, upload-time = "2025-10-05T18:41:41.766Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pynacl"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "cffi" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a7/22/27582568be639dfe22ddb3902225f91f2f17ceff88ce80e4db396c8986da/PyNaCl-1.5.0.tar.gz", hash = "sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba", size = 3392854, upload-time = "2022-01-07T22:05:41.134Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/75/0b8ede18506041c0bf23ac4d8e2971b4161cd6ce630b177d0a08eb0d8857/PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1", size = 349920, upload-time = "2022-01-07T22:05:49.156Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/bb/fddf10acd09637327a97ef89d2a9d621328850a72f1fdc8c08bdf72e385f/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92", size = 601722, upload-time = "2022-01-07T22:05:50.989Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/70/87a065c37cca41a75f2ce113a5a2c2aa7533be648b184ade58971b5f7ccc/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394", size = 680087, upload-time = "2022-01-07T22:05:52.539Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/87/f1bb6a595f14a327e8285b9eb54d41fef76c585a0edef0a45f6fc95de125/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d", size = 856678, upload-time = "2022-01-07T22:05:54.251Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/28/ca86676b69bf9f90e710571b67450508484388bfce09acf8a46f0b8c785f/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858", size = 1133660, upload-time = "2022-01-07T22:05:56.056Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/85/c262db650e86812585e2bc59e497a8f59948a005325a11bbbc9ecd3fe26b/PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b", size = 663824, upload-time = "2022-01-07T22:05:57.434Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/1a/cc308a884bd299b651f1633acb978e8596c71c33ca85e9dc9fa33a5399b9/PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff", size = 1117912, upload-time = "2022-01-07T22:05:58.665Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/2d/b7df6ddb0c2a33afdb358f8af6ea3b8c4d1196ca45497dd37a56f0c122be/PyNaCl-1.5.0-cp36-abi3-win32.whl", hash = "sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543", size = 204624, upload-time = "2022-01-07T22:06:00.085Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/22/d3db169895faaf3e2eda892f005f433a62db2decbcfbc2f61e6517adfa87/PyNaCl-1.5.0-cp36-abi3-win_amd64.whl", hash = "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93", size = 212141, upload-time = "2022-01-07T22:06:01.861Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "8.4.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-cov"
|
||||||
|
version = "7.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "coverage" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-mock"
|
||||||
|
version = "3.15.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyvmomi"
|
||||||
|
version = "9.0.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/e4/fbb539220f9d7647bf92543401f1b443cd43b25354237291e64618da3e4a/pyvmomi-9.0.0.0-py3-none-any.whl", hash = "sha256:7812642a62b6ce2b439d7e4856d27101ad102734bce41daf77bedfb3e2d9cbf2", size = 1993709, upload-time = "2025-06-17T16:54:05.865Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "redis"
|
||||||
|
version = "5.2.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/47/da/d283a37303a995cd36f8b92db85135153dc4f7a8e4441aa827721b442cfb/redis-5.2.1.tar.gz", hash = "sha256:16f2e22dff21d5125e8481515e386711a34cbec50f0e44413dd7d9c060a54e0f", size = 4608355, upload-time = "2024-12-06T09:50:41.956Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/5f/fa26b9b2672cbe30e07d9a5bdf39cf16e3b80b42916757c5f92bca88e4ba/redis-5.2.1-py3-none-any.whl", hash = "sha256:ee7e1056b9aea0f04c6c2ed59452947f34c4940ee025f5dd83e6a6418b6989e4", size = 261502, upload-time = "2024-12-06T09:50:39.656Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "requests"
|
||||||
|
version = "2.32.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "charset-normalizer" },
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "urllib3" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruff"
|
||||||
|
version = "0.13.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c7/8e/f9f9ca747fea8e3ac954e3690d4698c9737c23b51731d02df999c150b1c9/ruff-0.13.3.tar.gz", hash = "sha256:5b0ba0db740eefdfbcce4299f49e9eaefc643d4d007749d77d047c2bab19908e", size = 5438533, upload-time = "2025-10-02T19:29:31.582Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/33/8f7163553481466a92656d35dea9331095122bb84cf98210bef597dd2ecd/ruff-0.13.3-py3-none-linux_armv6l.whl", hash = "sha256:311860a4c5e19189c89d035638f500c1e191d283d0cc2f1600c8c80d6dcd430c", size = 12484040, upload-time = "2025-10-02T19:28:49.199Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/b5/4a21a4922e5dd6845e91896b0d9ef493574cbe061ef7d00a73c61db531af/ruff-0.13.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2bdad6512fb666b40fcadb65e33add2b040fc18a24997d2e47fee7d66f7fcae2", size = 13122975, upload-time = "2025-10-02T19:28:52.446Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/90/15649af836d88c9f154e5be87e64ae7d2b1baa5a3ef317cb0c8fafcd882d/ruff-0.13.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fc6fa4637284708d6ed4e5e970d52fc3b76a557d7b4e85a53013d9d201d93286", size = 12346621, upload-time = "2025-10-02T19:28:54.712Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/42/bcbccb8141305f9a6d3f72549dd82d1134299177cc7eaf832599700f95a7/ruff-0.13.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c9e6469864f94a98f412f20ea143d547e4c652f45e44f369d7b74ee78185838", size = 12574408, upload-time = "2025-10-02T19:28:56.679Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/19/0f3681c941cdcfa2d110ce4515624c07a964dc315d3100d889fcad3bfc9e/ruff-0.13.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5bf62b705f319476c78891e0e97e965b21db468b3c999086de8ffb0d40fd2822", size = 12285330, upload-time = "2025-10-02T19:28:58.79Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/f8/387976bf00d126b907bbd7725219257feea58650e6b055b29b224d8cb731/ruff-0.13.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78cc1abed87ce40cb07ee0667ce99dbc766c9f519eabfd948ed87295d8737c60", size = 13980815, upload-time = "2025-10-02T19:29:01.577Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/a6/7c8ec09d62d5a406e2b17d159e4817b63c945a8b9188a771193b7e1cc0b5/ruff-0.13.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4fb75e7c402d504f7a9a259e0442b96403fa4a7310ffe3588d11d7e170d2b1e3", size = 14987733, upload-time = "2025-10-02T19:29:04.036Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/e5/f403a60a12258e0fd0c2195341cfa170726f254c788673495d86ab5a9a9d/ruff-0.13.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:17b951f9d9afb39330b2bdd2dd144ce1c1335881c277837ac1b50bfd99985ed3", size = 14439848, upload-time = "2025-10-02T19:29:06.684Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/39/49/3de381343e89364c2334c9f3268b0349dc734fc18b2d99a302d0935c8345/ruff-0.13.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6052f8088728898e0a449f0dde8fafc7ed47e4d878168b211977e3e7e854f662", size = 13421890, upload-time = "2025-10-02T19:29:08.767Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ab/b5/c0feca27d45ae74185a6bacc399f5d8920ab82df2d732a17213fb86a2c4c/ruff-0.13.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc742c50f4ba72ce2a3be362bd359aef7d0d302bf7637a6f942eaa763bd292af", size = 13444870, upload-time = "2025-10-02T19:29:11.234Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/a1/b655298a1f3fda4fdc7340c3f671a4b260b009068fbeb3e4e151e9e3e1bf/ruff-0.13.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8e5640349493b378431637019366bbd73c927e515c9c1babfea3e932f5e68e1d", size = 13691599, upload-time = "2025-10-02T19:29:13.353Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/b0/a8705065b2dafae007bcae21354e6e2e832e03eb077bb6c8e523c2becb92/ruff-0.13.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6b139f638a80eae7073c691a5dd8d581e0ba319540be97c343d60fb12949c8d0", size = 12421893, upload-time = "2025-10-02T19:29:15.668Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/1e/cbe7082588d025cddbb2f23e6dfef08b1a2ef6d6f8328584ad3015b5cebd/ruff-0.13.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6b547def0a40054825de7cfa341039ebdfa51f3d4bfa6a0772940ed351d2746c", size = 12267220, upload-time = "2025-10-02T19:29:17.583Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/99/4086f9c43f85e0755996d09bdcb334b6fee9b1eabdf34e7d8b877fadf964/ruff-0.13.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9cc48a3564423915c93573f1981d57d101e617839bef38504f85f3677b3a0a3e", size = 13177818, upload-time = "2025-10-02T19:29:19.943Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/de/7b5db7e39947d9dc1c5f9f17b838ad6e680527d45288eeb568e860467010/ruff-0.13.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1a993b17ec03719c502881cb2d5f91771e8742f2ca6de740034433a97c561989", size = 13618715, upload-time = "2025-10-02T19:29:22.527Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/d3/bb25ee567ce2f61ac52430cf99f446b0e6d49bdfa4188699ad005fdd16aa/ruff-0.13.3-py3-none-win32.whl", hash = "sha256:f14e0d1fe6460f07814d03c6e32e815bff411505178a1f539a38f6097d3e8ee3", size = 12334488, upload-time = "2025-10-02T19:29:24.782Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/49/12f5955818a1139eed288753479ba9d996f6ea0b101784bb1fe6977ec128/ruff-0.13.3-py3-none-win_amd64.whl", hash = "sha256:621e2e5812b691d4f244638d693e640f188bacbb9bc793ddd46837cea0503dd2", size = 13455262, upload-time = "2025-10-02T19:29:26.882Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/72/7b83242b26627a00e3af70d0394d68f8f02750d642567af12983031777fc/ruff-0.13.3-py3-none-win_arm64.whl", hash = "sha256:9e9e9d699841eaf4c2c798fa783df2fabc680b72059a02ca0ed81c460bc58330", size = 12538484, upload-time = "2025-10-02T19:29:28.951Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "setuptools"
|
||||||
|
version = "80.9.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simplejson"
|
||||||
|
version = "3.20.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/af/92/51b417685abd96b31308b61b9acce7ec50d8e1de8fbc39a7fd4962c60689/simplejson-3.20.1.tar.gz", hash = "sha256:e64139b4ec4f1f24c142ff7dcafe55a22b811a74d86d66560c8815687143037d", size = 85591, upload-time = "2025-02-15T05:18:53.15Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/03/0f453a27877cb5a5fff16a975925f4119102cc8552f52536b9a98ef0431e/simplejson-3.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:71e849e7ceb2178344998cbe5ade101f1b329460243c79c27fbfc51c0447a7c3", size = 93109, upload-time = "2025-02-15T05:17:00.377Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/1f/a729f4026850cabeaff23e134646c3f455e86925d2533463420635ae54de/simplejson-3.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b63fdbab29dc3868d6f009a59797cefaba315fd43cd32ddd998ee1da28e50e29", size = 75475, upload-time = "2025-02-15T05:17:02.544Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/14/50a2713fee8ff1f8d655b1a14f4a0f1c0c7246768a1b3b3d12964a4ed5aa/simplejson-3.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1190f9a3ce644fd50ec277ac4a98c0517f532cfebdcc4bd975c0979a9f05e1fb", size = 75112, upload-time = "2025-02-15T05:17:03.875Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/86/ea9835abb646755140e2d482edc9bc1e91997ed19a59fd77ae4c6a0facea/simplejson-3.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1336ba7bcb722ad487cd265701ff0583c0bb6de638364ca947bb84ecc0015d1", size = 150245, upload-time = "2025-02-15T05:17:06.899Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/b4/53084809faede45da829fe571c65fbda8479d2a5b9c633f46b74124d56f5/simplejson-3.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e975aac6a5acd8b510eba58d5591e10a03e3d16c1cf8a8624ca177491f7230f0", size = 158465, upload-time = "2025-02-15T05:17:08.707Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/7d/d56579468d1660b3841e1f21c14490d103e33cf911886b22652d6e9683ec/simplejson-3.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a6dd11ee282937ad749da6f3b8d87952ad585b26e5edfa10da3ae2536c73078", size = 148514, upload-time = "2025-02-15T05:17:11.323Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/e3/874b1cca3d3897b486d3afdccc475eb3a09815bf1015b01cf7fcb52a55f0/simplejson-3.20.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab980fcc446ab87ea0879edad41a5c28f2d86020014eb035cf5161e8de4474c6", size = 152262, upload-time = "2025-02-15T05:17:13.543Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/84/f0fdb3625292d945c2bd13a814584603aebdb38cfbe5fe9be6b46fe598c4/simplejson-3.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f5aee2a4cb6b146bd17333ac623610f069f34e8f31d2f4f0c1a2186e50c594f0", size = 150164, upload-time = "2025-02-15T05:17:15.021Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/51/6d625247224f01eaaeabace9aec75ac5603a42f8ebcce02c486fbda8b428/simplejson-3.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:652d8eecbb9a3b6461b21ec7cf11fd0acbab144e45e600c817ecf18e4580b99e", size = 151795, upload-time = "2025-02-15T05:17:16.542Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/d9/bb921df6b35be8412f519e58e86d1060fddf3ad401b783e4862e0a74c4c1/simplejson-3.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8c09948f1a486a89251ee3a67c9f8c969b379f6ffff1a6064b41fea3bce0a112", size = 159027, upload-time = "2025-02-15T05:17:18.083Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/c5/5950605e4ad023a6621cf4c931b29fd3d2a9c1f36be937230bfc83d7271d/simplejson-3.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cbbd7b215ad4fc6f058b5dd4c26ee5c59f72e031dfda3ac183d7968a99e4ca3a", size = 154380, upload-time = "2025-02-15T05:17:20.334Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/ad/b74149557c5ec1e4e4d55758bda426f5d2ec0123cd01a53ae63b8de51fa3/simplejson-3.20.1-cp313-cp313-win32.whl", hash = "sha256:ae81e482476eaa088ef9d0120ae5345de924f23962c0c1e20abbdff597631f87", size = 74102, upload-time = "2025-02-15T05:17:22.475Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/a9/25282fdd24493e1022f30b7f5cdf804255c007218b2bfaa655bd7ad34b2d/simplejson-3.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:1b9fd15853b90aec3b1739f4471efbf1ac05066a2c7041bf8db821bb73cd2ddc", size = 75736, upload-time = "2025-02-15T05:17:24.122Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/30/00f02a0a921556dd5a6db1ef2926a1bc7a8bbbfb1c49cfed68a275b8ab2b/simplejson-3.20.1-py3-none-any.whl", hash = "sha256:8a6c1bbac39fa4a79f83cbf1df6ccd8ff7069582a9fd8db1e52cea073bc2c697", size = 57121, upload-time = "2025-02-15T05:18:51.243Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "six"
|
||||||
|
version = "1.17.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "snowballstemmer"
|
||||||
|
version = "3.0.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sphinx"
|
||||||
|
version = "7.4.7"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "alabaster" },
|
||||||
|
{ name = "babel" },
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "docutils" },
|
||||||
|
{ name = "imagesize" },
|
||||||
|
{ name = "jinja2" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
{ name = "requests" },
|
||||||
|
{ name = "snowballstemmer" },
|
||||||
|
{ name = "sphinxcontrib-applehelp" },
|
||||||
|
{ name = "sphinxcontrib-devhelp" },
|
||||||
|
{ name = "sphinxcontrib-htmlhelp" },
|
||||||
|
{ name = "sphinxcontrib-jsmath" },
|
||||||
|
{ name = "sphinxcontrib-qthelp" },
|
||||||
|
{ name = "sphinxcontrib-serializinghtml" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5b/be/50e50cb4f2eff47df05673d361095cafd95521d2a22521b920c67a372dcb/sphinx-7.4.7.tar.gz", hash = "sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe", size = 8067911, upload-time = "2024-07-20T14:46:56.059Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/ef/153f6803c5d5f8917dbb7f7fcf6d34a871ede3296fa89c2c703f5f8a6c8e/sphinx-7.4.7-py3-none-any.whl", hash = "sha256:c2419e2135d11f1951cd994d6eb18a1835bd8fdd8429f9ca375dc1f3281bd239", size = 3401624, upload-time = "2024-07-20T14:46:52.142Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sphinx-intl"
|
||||||
|
version = "2.3.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "babel" },
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "sphinx" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7a/21/eb12016ecb0b52861762b0d227dff75622988f238776a5ee4c75bade507e/sphinx_intl-2.3.2.tar.gz", hash = "sha256:04b0d8ea04d111a7ba278b17b7b3fe9625c58b6f8ffb78bb8a1dd1288d88c1c7", size = 27921, upload-time = "2025-08-02T04:53:01.891Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/3b/156032fa29a87e9eba9182b8e893a7e88c1d98907a078a371d69be432e52/sphinx_intl-2.3.2-py3-none-any.whl", hash = "sha256:f0082f9383066bab8406129a2ed531d21c38706d08467bf5ca3714e8914bb2be", size = 12899, upload-time = "2025-08-02T04:53:00.353Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sphinx-rtd-theme"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "sphinx" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/4e/e5/0d55470572e0a0934c600c4cda0c98209883aaeb45ff6bfbadcda7006255/sphinx_rtd_theme-0.5.1.tar.gz", hash = "sha256:eda689eda0c7301a80cf122dad28b1861e5605cbf455558f3775e1e8200e83a5", size = 2774928, upload-time = "2021-01-04T22:57:24.103Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/81/d5af3a50a45ee4311ac2dac5b599d69f68388401c7a4ca902e0e450a9f94/sphinx_rtd_theme-0.5.1-py2.py3-none-any.whl", hash = "sha256:fa6bebd5ab9a73da8e102509a86f3fcc36dec04a0b52ea80e5a033b2aba00113", size = 2793140, upload-time = "2021-01-04T22:57:15.177Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sphinx-tabs"
|
||||||
|
version = "3.4.7"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "docutils" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
{ name = "sphinx" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/6a/53/a9a91995cb365e589f413b77fc75f1c0e9b4ac61bfa8da52a779ad855cc0/sphinx-tabs-3.4.7.tar.gz", hash = "sha256:991ad4a424ff54119799ba1491701aa8130dd43509474aef45a81c42d889784d", size = 15891, upload-time = "2024-10-08T13:37:27.887Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/c6/f47505b564b918a3ba60c1e99232d4942c4a7e44ecaae603e829e3d05dae/sphinx_tabs-3.4.7-py3-none-any.whl", hash = "sha256:c12d7a36fd413b369e9e9967a0a4015781b71a9c393575419834f19204bd1915", size = 9727, upload-time = "2024-10-08T13:37:26.192Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sphinxcontrib-applehelp"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sphinxcontrib-devhelp"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sphinxcontrib-htmlhelp"
|
||||||
|
version = "2.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sphinxcontrib-jsmath"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sphinxcontrib-qthelp"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sphinxcontrib-serializinghtml"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sphinxjp-themes-revealjs"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "setuptools" },
|
||||||
|
{ name = "sphinx" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/69/ef/b386f35b6caf57f158da97d594df78f07eaf42fd0044b361fe5fd143238b/sphinxjp.themes.revealjs-0.3.0.tar.gz", hash = "sha256:293ea6a91bcb74a19648373f0194854ddd7757bb578407627c8ef73caaa9ed3c", size = 5462188, upload-time = "2015-05-06T13:35:39.5Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/8f/538bbbd5e1c63330eb8793d0e9c2faa466a7b82e2f8007a5c2e196e2c0d1/sphinxjp.themes.revealjs-0.3.0-py2.py3-none-any.whl", hash = "sha256:631914655ed37241a808452c1d95a0dfcef92c1c42cdcfb2f031a11cb0366689", size = 1637836, upload-time = "2015-05-06T13:35:47.844Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tisbackup"
|
||||||
|
version = "1.8.0"
|
||||||
|
source = { virtual = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "authlib" },
|
||||||
|
{ name = "bcrypt" },
|
||||||
|
{ name = "flask" },
|
||||||
|
{ name = "flask-login" },
|
||||||
|
{ name = "huey" },
|
||||||
|
{ name = "iniparse" },
|
||||||
|
{ name = "paramiko" },
|
||||||
|
{ name = "peewee" },
|
||||||
|
{ name = "pexpect" },
|
||||||
|
{ name = "pyvmomi" },
|
||||||
|
{ name = "redis" },
|
||||||
|
{ name = "requests" },
|
||||||
|
{ name = "ruff" },
|
||||||
|
{ name = "simplejson" },
|
||||||
|
{ name = "six" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
docs = [
|
||||||
|
{ name = "docutils" },
|
||||||
|
{ name = "sphinx" },
|
||||||
|
{ name = "sphinx-intl" },
|
||||||
|
{ name = "sphinx-rtd-theme" },
|
||||||
|
{ name = "sphinx-tabs" },
|
||||||
|
{ name = "sphinxjp-themes-revealjs" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "pylint" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-cov" },
|
||||||
|
{ name = "pytest-mock" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "authlib", specifier = ">=1.3.0" },
|
||||||
|
{ name = "bcrypt", specifier = ">=4.0.0" },
|
||||||
|
{ name = "docutils", marker = "extra == 'docs'" },
|
||||||
|
{ name = "flask", specifier = "==3.1.0" },
|
||||||
|
{ name = "flask-login", specifier = ">=0.6.0" },
|
||||||
|
{ name = "huey", specifier = "==2.5.3" },
|
||||||
|
{ name = "iniparse", specifier = "==0.5" },
|
||||||
|
{ name = "paramiko", specifier = "==4.0.0" },
|
||||||
|
{ name = "peewee", specifier = "==3.17.9" },
|
||||||
|
{ name = "pexpect", specifier = "==4.9.0" },
|
||||||
|
{ name = "pyvmomi", specifier = ">=8.0.0" },
|
||||||
|
{ name = "redis", specifier = "==5.2.1" },
|
||||||
|
{ name = "requests", specifier = "==2.32.3" },
|
||||||
|
{ name = "ruff", specifier = ">=0.13.3" },
|
||||||
|
{ name = "simplejson", specifier = "==3.20.1" },
|
||||||
|
{ name = "six", specifier = "==1.17.0" },
|
||||||
|
{ name = "sphinx", marker = "extra == 'docs'", specifier = ">=7.0.0,<8.0.0" },
|
||||||
|
{ name = "sphinx-intl", marker = "extra == 'docs'" },
|
||||||
|
{ name = "sphinx-rtd-theme", marker = "extra == 'docs'" },
|
||||||
|
{ name = "sphinx-tabs", marker = "extra == 'docs'" },
|
||||||
|
{ name = "sphinxjp-themes-revealjs", marker = "extra == 'docs'" },
|
||||||
|
]
|
||||||
|
provides-extras = ["docs"]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [
|
||||||
|
{ name = "pylint", specifier = ">=3.0.0" },
|
||||||
|
{ name = "pytest", specifier = ">=8.4.2" },
|
||||||
|
{ name = "pytest-cov", specifier = ">=6.0.0" },
|
||||||
|
{ name = "pytest-mock", specifier = ">=3.15.1" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tomlkit"
|
||||||
|
version = "0.13.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa38819ebe46a7bd25dcd56b67434402b66a58c4b8e552575/tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", size = 185207, upload-time = "2025-06-05T07:13:44.947Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urllib3"
|
||||||
|
version = "2.4.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e442a35feac5e00f0913c0f4b7c217c11e8ec2ff53e0/urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466", size = 390672, upload-time = "2025-04-10T15:23:39.232Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680, upload-time = "2025-04-10T15:23:37.377Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "werkzeug"
|
||||||
|
version = "3.1.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markupsafe" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9f/69/83029f1f6300c5fb2471d621ab06f6ec6b3324685a2ce0f9777fd4a8b71e/werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746", size = 806925, upload-time = "2024-11-08T15:52:18.093Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498, upload-time = "2024-11-08T15:52:16.132Z" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user