Compare commits

34 Commits

Author SHA1 Message Date
k3nny 4337b0e925 fix(tisbackup): update to python 3.14
lint / docker (push) Has been cancelled
2026-06-05 00:13:16 +02:00
k3nny 1cb731cbdb refactor(drivers): organize backup modules into drivers subfolder
lint / docker (push) Has been cancelled
- Move all backup_*.py files to libtisbackup/drivers/ subdirectory
- Move XenAPI.py and copy_vm_xcp.py to drivers/ (driver-specific)
- Create drivers/__init__.py with automatic driver imports
- Update tisbackup.py imports to use new structure
- Add pyvmomi>=8.0.0 as mandatory dependency
- Sync requirements.txt with pyproject.toml dependencies
- Add pylint>=3.0.0 and pytest-cov>=6.0.0 to dev dependencies
- Configure pylint and coverage tools in pyproject.toml
- Add conventional commits guidelines to CLAUDE.md
- Enhance .gitignore with comprehensive patterns for Python, IDEs, testing, and secrets
- Update CLAUDE.md documentation with new structure and tooling

Breaking Changes:
- Drivers must now be imported from libtisbackup.drivers instead of libtisbackup
- All backup driver files relocated to drivers/ subdirectory

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 23:54:26 +02:00
k3nny 38a0d788d4 feat(auth): install all authentication providers by default
lint / docker (push) Waiting to run
All authentication methods (Basic Auth, Flask-Login, OAuth) are now
installed as core dependencies instead of optional extras. This
simplifies setup and eliminates the need to run `uv sync --extra auth-*`
when switching between authentication methods.

Changes:
- Move authlib, bcrypt, and flask-login to core dependencies
- Remove auth-* optional dependency groups from pyproject.toml
- Update documentation to remove installation instructions
- Simplify troubleshooting and migration guides

Benefits:
- No import errors when switching auth methods
- Simpler user experience
- All providers available out of the box

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 21:17:30 +02:00
k3nny 12f35934a9 docs: add comprehensive security and authentication documentation
lint / docker (push) Waiting to run
Add new documentation sections covering security best practices and authentication system architecture. Update Sphinx configuration and dependencies to support documentation improvements.

Changes include:
- New security.rst with SSH key management, network security, secrets management
- New authentication.rst documenting pluggable auth system and provider setup
- Updated Sphinx config to use Alabaster theme and add sphinx-tabs extension
- Added docs extra dependencies in pyproject.toml for documentation builds
- Updated example configs to use Ed25519 instead of deprecated DSA keys
- Added .python-version file for consistent Python version management
- Added CLAUDE.md project instructions for AI-assisted development
- Minor Dockerfile cleanup removing commented pip install line

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 20:58:19 +02:00
k3nny e6ee91babf feat(auth): enable Basic Auth as default authentication method
- Initialize authentication system on Flask app startup
- Default to Basic Auth if no [authentication] section in config
- Support TISBACKUP_AUTH_USERNAME and TISBACKUP_AUTH_PASSWORD env vars
- Generate secure random password if not configured with warning
- Protect all Flask routes with @auth.require_auth decorator
- Fallback to 'none' auth provider on initialization errors

Routes protected:
- / (backup_all)
- /config_number/ (set_config_number)
- /all_json (backup_all_json)
- /json (backup_json)
- /status.json (export_backup_status)
- /backups.json (last_backup_json)
- /last_backups (last_backup)
- /export_backup (export_backup)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 02:11:41 +02:00
k3nny f12d89f3da feat(auth): add pluggable authentication system for Flask routes
Implement comprehensive authentication system with support for
Basic Auth, Flask-Login, and OAuth2 providers.

Features:
- Pluggable architecture via factory pattern
- Multiple authentication providers:
  * None: No authentication (development/testing)
  * Basic Auth: HTTP Basic with bcrypt support
  * Flask-Login: Session-based with multiple users
  * OAuth2: Google, GitHub, GitLab, and generic providers
- Decorator-based route protection (@auth.require_auth)
- User authorization by domain or email (OAuth)
- bcrypt password hashing support
- Comprehensive documentation and examples

Components:
- libtisbackup/auth/__init__.py: Factory function and exports
- libtisbackup/auth/base.py: Base provider interface
- libtisbackup/auth/basic_auth.py: HTTP Basic Auth implementation
- libtisbackup/auth/flask_login_auth.py: Flask-Login implementation
- libtisbackup/auth/oauth_auth.py: OAuth2 implementation
- libtisbackup/auth/example_integration.py: Integration examples
- libtisbackup/auth/README.md: API reference and examples

Documentation:
- AUTHENTICATION.md: Complete authentication guide
  * Setup instructions for each provider
  * Configuration examples
  * Security best practices
  * Troubleshooting guide
  * Migration guide
- samples/auth-config-examples.ini: Configuration templates

Dependencies:
- Add optional dependencies in pyproject.toml:
  * auth-basic: bcrypt>=4.0.0
  * auth-login: flask-login>=0.6.0, bcrypt>=4.0.0
  * auth-oauth: authlib>=1.3.0, requests>=2.32.0
  * auth-all: All auth providers

Installation:
```bash
# Install specific provider
uv sync --extra auth-basic

# Install all providers
uv sync --extra auth-all
```

Usage:
```python
from libtisbackup.auth import get_auth_provider

# Initialize
auth = get_auth_provider("basic", {
    "username": "admin",
    "password": "$2b$12$...",
    "use_bcrypt": True
})
auth.init_app(app)

# Protect routes
@app.route("/")
@auth.require_auth
def index():
    user = auth.get_current_user()
    return f"Hello {user['username']}"
```

Security features:
- bcrypt password hashing (work factor 12)
- OAuth domain/user restrictions
- Session-based authentication
- Clear separation of concerns
- Environment variable support for secrets

OAuth providers supported:
- Google (OpenID Connect)
- GitHub
- GitLab
- Generic OAuth2 provider

Breaking change: None - new feature, backward compatible
Users can continue without authentication (type=none)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 02:02:46 +02:00
k3nny d130ba2a11 docs: comprehensive README rewrite with security improvements
Completely rewrite README.md based on codebase analysis and
implemented security improvements.

Changes:
- Add comprehensive overview with feature list
- Add supported backup types table with all 10+ drivers
- Restructure Quick Start with step-by-step installation
- Add detailed configuration examples for each backup type
- Document all CLI commands with Docker exec examples
- Add dedicated Security section highlighting improvements
- Include reverse proxy setup with security headers
- Add Troubleshooting section with common issues
- Add Development section with uv commands
- Reorganize into logical sections with clear hierarchy

Improvements:
- Emphasize Ed25519 as recommended SSH key algorithm
- Document Flask secret key security requirement
- Include security best practices section
- Add command execution safety information
- Provide nginx reverse proxy example with TLS
- Include proper file permissions instructions

Documentation structure:
1. Overview and features
2. Quick Start (10-step installation)
3. Configuration (by backup type)
4. CLI Usage (all commands)
5. Development setup
6. Security (best practices)
7. Reverse Proxy setup
8. Architecture overview
9. Troubleshooting
10. Contributing and support

Target audience:
- New users: Clear installation steps
- Existing users: Migration to Ed25519 keys
- Developers: Development environment setup
- Security-conscious admins: Best practices

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 01:47:45 +02:00
k3nny 2533b56549 feat(security): modernize SSH key algorithm support with Ed25519
Replace deprecated DSA key support with modern SSH key algorithms,
prioritizing Ed25519 as the most secure option.

Changes:
- Add load_ssh_private_key() helper function in common.py
- Support Ed25519 (preferred), ECDSA, and RSA key types
- Remove deprecated and insecure DSA key support
- Update all SSH key loading across backup drivers:
  * common.py: do_preexec, do_postexec, run_remote_command
  * backup_mysql.py
  * backup_pgsql.py
  * backup_sqlserver.py
  * backup_oracle.py
  * backup_samba4.py
- Add ssh_port parameter to preexec/postexec connections
- Update README.md with SSH key generation instructions
- Document supported algorithms and migration path

Algorithm priority:
1. Ed25519 (most secure, modern, fast, timing-attack resistant)
2. ECDSA (secure, widely supported)
3. RSA (legacy support, requires 2048+ bits)

Security improvements:
- Eliminates vulnerable DSA algorithm (1024-bit limit, FIPS deprecated)
- Prioritizes elliptic curve cryptography (Ed25519, ECDSA)
- Provides clear error messages for unsupported key types
- Maintains backward compatibility with existing RSA keys

Documentation:
- Add SSH key generation examples to README.md
- Update expected directory structure to show Ed25519 keys
- Add migration notes in SECURITY_IMPROVEMENTS.md
- Include key generation commands for all supported types

Breaking change:
- DSA keys are no longer supported and will fail with clear error message
- Users must migrate to Ed25519, ECDSA, or RSA (4096-bit recommended)

Migration:
```bash
# Generate new Ed25519 key
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519

# Copy to remote servers
ssh-copy-id -i ~/.ssh/id_ed25519.pub user@remote
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 01:39:17 +02:00
k3nny 68ff4238e0 fix(security): remove hardcoded Flask secret key
Replace hardcoded Flask secret key with environment variable to
prevent session hijacking and CSRF attacks.

Changes:
- Load secret key from TISBACKUP_SECRET_KEY environment variable
- Fall back to cryptographically secure random key using secrets module
- Log warning when random key is used (sessions won't persist)
- Add environment variable example to README.md Docker Compose config
- Add setup instructions in Configuration section

Security improvements:
- Eliminates hardcoded secret in source code
- Uses secrets.token_hex(32) for cryptographically strong random generation
- Sessions remain secure even without env var (though won't persist)
- Prevents session hijacking and CSRF bypass attacks

Documentation:
- Update README.md with TISBACKUP_SECRET_KEY setup instructions
- Include command to generate secure random key
- Update SECURITY_IMPROVEMENTS.md with implementation details
- Mark hardcoded secret key issue as resolved

Setup:
```bash
# Generate secure key
python3 -c "import secrets; print(secrets.token_hex(32))"

# Set in environment
export TISBACKUP_SECRET_KEY=your-key-here
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 01:29:16 +02:00
k3nny debc753f13 fix(security): replace os.popen/os.system with subprocess for command injection prevention
Replace all deprecated and unsafe command execution methods with
secure subprocess.run() calls using list arguments.

Changes:
- Replace os.popen() with subprocess.run() in tisbackup_gui.py
- Replace os.system() with subprocess.run() in tasks.py and backup_xva.py
- Add input validation for device/partition names (regex-based)
- Fix file operations to use context managers (with statement)
- Remove wildcard import from shutil
- Add timeout protection to all subprocess calls (5-30s)
- Improve error handling with proper try/except blocks

Security improvements:
- Prevent command injection vulnerabilities in USB disk operations
- Validate device paths with regex before system calls
- Use list arguments instead of shell=True to prevent injection
- Add proper error handling instead of silent failures

Code quality improvements:
- Replace deprecated os.popen() (deprecated since Python 2.6)
- Use context managers for file operations
- Remove wildcard imports for cleaner namespace
- Add comprehensive error handling and logging

Documentation:
- Add SECURITY_IMPROVEMENTS.md documenting all changes
- Document remaining security issues and recommendations
- Include testing recommendations and migration notes

BREAKING CHANGE: None - all changes are backward compatible

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 01:23:53 +02:00
k3nny c586bd1817 Merge 'feat/refacto' (#1) into master
lint / docker (push) Has been cancelled
Utilisation de uv
2025-04-19 00:04:39 +02:00
k3nny e823f65c3c fix(tisbackup): 🐛 remove excess uv/uvx 2025-04-18 23:57:44 +02:00
k3nny 5c627f3a64 fix(tisbackup): 🐛 Dockerfile fix venv uv 2025-04-18 23:48:25 +02:00
k3nny 7b6ce02a93 fix(tisbackup): 🐛 fix dockerignore pyproject.toml absent 2025-04-18 23:36:26 +02:00
k3nny e7d3e1140c fix(tisbackup): using uv is good in Dockerfile maybe 2025-04-18 23:32:15 +02:00
k3nny 6fe3eebf36 fix(tisbackup): using uv is good 2025-04-18 23:11:05 +02:00
k3nny 79d15628bd fix(tisbackup): add elements to .dockerignore - bis
lint / docker (push) Successful in 9m17s
2025-04-14 23:54:51 +02:00
k3nny 3a4f3267eb fix(tisbackup): add elements to .dockerignore
lint / docker (push) Has been cancelled
2025-04-14 23:50:42 +02:00
k3nny 8761a04c40 fix(tisbackup): add .dockerignore
lint / docker (push) Has been cancelled
2025-04-14 23:45:53 +02:00
k3nny 586991bcf1 fix(tisbackup): fix iniparse wrong check
lint / docker (push) Has been cancelled
2025-04-14 23:37:16 +02:00
k3nny ddb5f3716d Fix replace
lint / docker (push) Successful in 9m16s
2025-03-07 22:54:14 +01:00
k3nny b805f8387e Fix re.compile / re.match warnings
lint / docker (push) Has been cancelled
2025-03-07 22:51:20 +01:00
k3nny da50051a3f Python 3.13 + add nginx reverse-proxy
lint / docker (push) Successful in 14m2s
2025-03-07 22:24:27 +01:00
k3nny 8ef9bbde06 improve README.md
lint / docker (push) Successful in 9m15s
2024-11-30 00:20:51 +01:00
k3nny 737f9bea38 fix iniparse
lint / docker (push) Successful in 9m14s
fix code passing ruff linter
pre-commit ruff
pre-commit ruff format
2024-11-29 23:45:40 +01:00
k3nny aa8a68aa80 EOF & whitespace
lint / docker (push) Failing after 4m47s
2024-11-29 00:54:31 +01:00
k3nny 7fcc5afc64 EOF & whitespace 2024-11-29 00:54:09 +01:00
k3nny e7e98d0b47 few fixes and lint compatible 2024-11-29 00:48:59 +01:00
k3nny 8479c378ee fix basic 2024-11-29 00:32:39 +01:00
k3nny 274e1e2e59 requirements.txt 2024-11-29 00:02:24 +01:00
k3nny eb0bdaedbd fix import 2024-11-28 23:59:02 +01:00
k3nny 99dc6e0abf fix import 2024-11-28 23:46:48 +01:00
k3nny e8ba6df102 fix first pass - .gitignore 2024-11-28 23:21:26 +01:00
k3nny ffd9bf3d39 fix first pass 2024-11-28 23:20:19 +01:00
127 changed files with 14853 additions and 8308 deletions
+101
View File
@@ -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/
+2 -3
View File
@@ -14,12 +14,11 @@ jobs:
- name: Install Python
uses: actions/setup-python@v4
with:
python-version: '3.12'
python-version: '3.14'
cache: 'pip' # caching pip dependencies
- run: pip install ruff
- run: |
- run: |
ruff check .
ruff fix .
# - uses: stefanzweifel/git-auto-commit-action@v4
# with:
# commit_message: 'style fixes by ruff'
+130 -9
View File
@@ -1,16 +1,137 @@
*.bak
*.swp
*~
# ===============================================
# TISBackup .gitignore
# ===============================================
# Python compiled files
# ===============================================
*.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-wal
/srvinstallation
/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
/lib
/rpm/*.rpm
/rpm/RPMS
/rpm/BUILD
/rpm/RPMS/
/rpm/BUILD/
/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/
+13
View File
@@ -0,0 +1,13 @@
DL3008failure-threshold: warning
format: tty
ignored:
- DL3007
override:
error:
- DL3015
warning:
- DL3015
info:
- DL3008
style:
- DL3015
+16
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
3.14
+1 -1
View File
@@ -19,4 +19,4 @@
"console": "integratedTerminal"
}
]
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"conventionalCommits.scopes": [
"tisbackup"
]
}
+410
View File
@@ -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
+253
View File
@@ -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
View File
@@ -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"]
+480 -7
View File
@@ -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
at regular intervals to retrieve different data types on remote hosts
such as database dumps, files, virtual machine images and metadata.
A comprehensive server-side backup orchestration system for managing automated backups of databases, files, and virtual machines across remote Linux and Windows systems.
:ref:`Tranquil IT <contact_tranquil_it>` is the original author of TISBackup.
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
[![Python 3.14+](https://img.shields.io/badge/python-3.14+-blue.svg)](https://www.python.org/downloads/)
The documentation is provided under the license CC-BY-SA and can be found
on `readthedoc <https://tisbackup.readthedocs.io/en/latest/index.html>`_.
## Overview
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
View File
@@ -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
+272
View File
@@ -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)
+5
View File
@@ -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
View File
@@ -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
Regular → Executable
+6 -7
View File
@@ -1,10 +1,9 @@
import os,sys
from huey.backends.sqlite_backend import SqliteQueue,SqliteDataStore
from huey.api import Huey, create_task
import os
import sys
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__)))
tasks_db = os.path.join(tisbackup_root_dir,"tasks.sqlite")
queue = SqliteQueue('tisbackups',tasks_db)
result_store = SqliteDataStore('tisbackups',tasks_db)
huey = Huey(queue,result_store,always_eager=False)
tasks_db = os.path.join(tisbackup_root_dir, "tasks.sqlite")
huey = SqlHuey(name="tisbackups", filename=tasks_db, always_eager=False, storage_class=SqliteStorage)
Executable
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
set -x
echo "Starting cron job for TIS Backup"
cron -f -l 2
+11 -13
View File
@@ -1,42 +1,42 @@
## tisbackup for python3
## tisbackup for python3
### Install
Once the deb package is created, one can use it to install tisbackup on a debian machine. The command is:
Once the deb package is created, one can use it to install tisbackup on a debian machine. The command is:
```
apt install ./tis-tisbackup-1-2-0.170-deb11.deb
```
```
Note that the version numbers might be different depending on the system you used to build the package.
Then create a directory where to backup the files from your machines. The default is ```/backup```.
This can be changed in the configuration file ```/etc/tis/tisback-config.ini```. Usually this
directory is mounted from a shared ressource on a NAS with great capacity.
directory is mounted from a shared ressource on a NAS with great capacity.
Configure your backup jobs:
Configure your backup jobs:
```
cd /etc/tis
cp tisbackup-config.ini.sample tisbackup-config.ini
vi tisbackup-config.ini
```
```
After this, one have to generate the public and private certificates, as root:
After this, one have to generate the public and private certificates, as root:
```
cd
ssh-keygen -t rsa -b 2048
```
```
(press enter for each step)
Then propagate the public certificate on the machines targetted for backup:
Then propagate the public certificate on the machines targetted for backup:
```
ssh-copy-id -i /root/.ssh/id_rsa.pub root@machine1
ssh-copy-id -i /root/.ssh/id_rsa.pub root@machine2
```
```
etc.
Eventually modify ```/etc/cron.d/tisbackup``` for your needs.
Finalize the installation with:
Finalize the installation with:
```
tisbackup -d backup
systemctl start tisbackup_gui
@@ -52,5 +52,3 @@ The documentation for tisbackup is here: [tisbackup doc](https://tisbackup.readt
dpkg --force-all --purge tis-tisbackup
apt autoremove
```
+2 -3
View File
@@ -3,8 +3,7 @@ Version: 1-__VERSION__
Section: base
Priority: optional
Architecture: all
Depends: unzip, ssh, rsync, python3-paramiko, python3-pyvmomi, python3-pexpect, python3-flask,python3-simplejson, python3-pip
Depends: unzip, ssh, rsync, python3-paramiko, python3-pyvmomi, python3-pexpect, python3-flask,python3-simplejson, python3-pip
Maintainer: Tranquil-IT <technique@tranquil.it>
Description: TISBackup backup management
Description: TISBackup backup management
Homepage: https://www.tranquil.it
+2 -4
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env bash
VERSION_DEB=$(cat /etc/debian_version | cut -d "." -f 1)
VERSION_DEB=$(cat /etc/debian_version | cut -d "." -f 1)
VERSION_SHORT=$(cat ../tisbackup.py | grep "__version__" | cut -d "=" -f 2 | sed 's/"//g')
GIT_COUNT=`git rev-list HEAD --count`
GIT_COUNT=`git rev-list HEAD --count`
VERSION="${VERSION_SHORT}.${GIT_COUNT}-deb${VERSION_DEB}"
rm -f *.deb
@@ -32,5 +32,3 @@ rsync -aP ../samples/tisbackup-config.ini.sample ./builddir/etc/tis/tisbackup-c
chmod 755 ./builddir/opt/tisbackup/tisbackup.py
dpkg-deb --build builddir tis-tisbackup-1-${VERSION}.deb
+372
View File
@@ -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.
+52 -63
View File
@@ -30,50 +30,51 @@
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
'sphinx.ext.doctest',
'sphinx.ext.intersphinx',
'sphinx.ext.todo',
'sphinx.ext.viewcode',
'sphinx.ext.githubpages',
"sphinx.ext.doctest",
"sphinx.ext.intersphinx",
"sphinx.ext.todo",
"sphinx.ext.viewcode",
"sphinx.ext.githubpages",
"sphinx_tabs.tabs",
]
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
templates_path = ["_templates"]
# The suffix(es) of source filenames.
# You can specify multiple suffix as a list of string:
#
# source_suffix = ['.rst', '.md']
source_suffix = '.rst'
source_suffix = ".rst"
# The encoding of source files.
#
# source_encoding = 'utf-8-sig'
# The master toctree document.
master_doc = 'index'
master_doc = "index"
# General information about the project.
project = 'TISBackup'
copyright = '2020, Tranquil IT'
author = 'Tranquil IT'
project = "TISBackup"
copyright = "2020, Tranquil IT"
author = "Tranquil IT"
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
version = '1.8'
version = "1.8"
# 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
# for a list of supported languages.
#
# This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases.
language = 'en'
locale_dirs = ['locale/']
language = "en"
locale_dirs = ["locale/"]
gettext_compact = False
# There are two options for replacing |today|: either, you set today to some
@@ -110,7 +111,7 @@ exclude_patterns = []
# show_authors = False
# 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.
# modindex_common_prefix = []
@@ -124,21 +125,9 @@ todo_include_todos = True
# -- Options for HTML output ----------------------------------------------
try:
import sphinx_rtd_theme
html_theme = "sphinx_rtd_theme"
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 = []
html_theme = "alabaster"
html_theme_path = []
html_favicon = "_static/favicon.ico"
# 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,
# relative to this directory. They are copied after the builtin static files,
# 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
# .htaccess) here, relative to this directory. These files are copied
@@ -258,15 +247,13 @@ html_static_path = ['_static']
# html_search_scorer = 'scorer.js'
# Output file base name for HTML help builder.
htmlhelp_basename = 'tisbackupdoc'
htmlhelp_basename = "tisbackupdoc"
# -- Linkcheck -------------------
# make linkcheck
# URL patterns to ignore
linkcheck_ignore = [r'http.*://.*mydomain.lan.*',
r'http.*://.*host_fqdn.*',
r'http://user:pwd@host_fqdn:port']
linkcheck_ignore = [r"http.*://.*mydomain.lan.*", r"http.*://.*host_fqdn.*", r"http://user:pwd@host_fqdn:port"]
# -- Options for LaTeX output ---------------------------------------------
@@ -279,23 +266,20 @@ linkcheck_ignore = [r'http.*://.*mydomain.lan.*',
# > \setlength\paperwidth {15.59cm}}
latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#
# 'papersize': 'letterpaper',
'papersize': 'lulupaper',
# The font size ('10pt', '11pt' or '12pt').
#
'pointsize': '9pt',
# Additional stuff for the LaTeX preamble.
#
'preamble': r'\batchmode',
# Latex figure (float) alignment
#
# 'figure_align': 'htbp',
'sphinxsetup': 'hmargin={1.5cm,1.5cm}, vmargin={3cm,3cm}, marginpar=1cm',
# The paper size ('letterpaper' or 'a4paper').
#
# 'papersize': 'letterpaper',
"papersize": "lulupaper",
# The font size ('10pt', '11pt' or '12pt').
#
"pointsize": "9pt",
# Additional stuff for the LaTeX preamble.
#
"preamble": r"\batchmode",
# Latex figure (float) alignment
#
# '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,
# author, documentclass [howto, manual, or own class]).
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
@@ -343,10 +327,7 @@ latex_documents = [
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
(master_doc, 'tisbackup', 'TISBackup Documentation',
[author], 1)
]
man_pages = [(master_doc, "tisbackup", "TISBackup Documentation", [author], 1)]
# If true, show URL addresses after external links.
#
@@ -359,9 +340,15 @@ man_pages = [
# (source start file, target name, title, author,
# dir menu entry, description, category)
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.',
'Miscellaneous'),
(
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.',
"Miscellaneous",
),
]
# 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.
intersphinx_mapping = {'https://docs.python.org/': None}
intersphinx_mapping = {
"python": ("https://docs.python.org/3", None),
}
# -- Options for Epub output ----------------------------------------------
@@ -438,7 +427,7 @@ epub_copyright = copyright
# epub_post_files = []
# 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.
#
@@ -82,7 +82,7 @@ Backing up a MySQL database
[srvintranet_mysql_mediawiki]
type=mysql+ssh
server_name=srvintranet
private_key=/root/.ssh/id_dsa
private_key=/root/.ssh/id_ed25519
db_name=mediawiki
db_user=user
db_passwd=password
@@ -141,7 +141,7 @@ Backing up a file server
type=rsync+ssh
server_name=srvfiles
remote_dir=/home
private_key=/root/.ssh/id_dsa
private_key=/root/.ssh/id_ed25519
exclude_list=".mozilla",".thunderbird",".x2go","*.avi"
bwlimit = 100
+7
View File
@@ -92,6 +92,13 @@ would have been difficult to develop as an overlay of the existing one:
configuring_tisbackup.rst
using_tisbackup.rst
.. toctree::
:maxdepth: 2
:caption: Security & Authentication
security.rst
authentication.rst
.. toctree::
:maxdepth: 1
:caption: Appendix
@@ -251,14 +251,24 @@ Launching the backup scheduled task
Generating the public and private certificates
++++++++++++++++++++++++++++++++++++++++++++++
* as root:
* as root, generate an Ed25519 SSH key (modern and secure algorithm):
.. 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;
.. 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
and :ref:`configure the backup jobs for your TISBackup<configuring_backup_jobs>`.
+288
View File
@@ -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
+1 -1
View File
@@ -765,4 +765,4 @@ div.math:hover a.headerlink {
#top-link {
display: none;
}
}
}
+1 -1
View File
@@ -1 +1 @@
.fa:before{-webkit-font-smoothing:antialiased}.clearfix{*zoom:1}.clearfix:after,.clearfix:before{display:table;content:""}.clearfix:after{clear:both}@font-face{font-family:FontAwesome;font-style:normal;font-weight:400;src:url(fonts/fontawesome-webfont.eot?674f50d287a8c48dc19ba404d20fe713?#iefix) format("embedded-opentype"),url(fonts/fontawesome-webfont.woff2?af7ae505a9eed503f8b8e6982036873e) format("woff2"),url(fonts/fontawesome-webfont.woff?fee66e712a8a08eef5805a46892932ad) format("woff"),url(fonts/fontawesome-webfont.ttf?b06871f281fee6b241d60582ae9369b9) format("truetype"),url(fonts/fontawesome-webfont.svg?912ec66d7572ff821749319396470bde#FontAwesome) format("svg")}.fa:before{font-family:FontAwesome;font-style:normal;font-weight:400;line-height:1}.fa:before,a .fa{text-decoration:inherit}.fa:before,a .fa,li .fa{display:inline-block}li .fa-large:before{width:1.875em}ul.fas{list-style-type:none;margin-left:2em;text-indent:-.8em}ul.fas li .fa{width:.8em}ul.fas li .fa-large:before{vertical-align:baseline}.fa-book:before,.icon-book:before{content:"\f02d"}.fa-caret-down:before,.icon-caret-down:before{content:"\f0d7"}.fa-caret-up:before,.icon-caret-up:before{content:"\f0d8"}.fa-caret-left:before,.icon-caret-left:before{content:"\f0d9"}.fa-caret-right:before,.icon-caret-right:before{content:"\f0da"}.rst-versions{position:fixed;bottom:0;left:0;width:300px;color:#fcfcfc;background:#1f1d1d;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;z-index:400}.rst-versions a{color:#2980b9;text-decoration:none}.rst-versions .rst-badge-small{display:none}.rst-versions .rst-current-version{padding:12px;background-color:#272525;display:block;text-align:right;font-size:90%;cursor:pointer;color:#27ae60}.rst-versions .rst-current-version:after{clear:both;content:"";display:block}.rst-versions .rst-current-version .fa{color:#fcfcfc}.rst-versions .rst-current-version .fa-book,.rst-versions .rst-current-version .icon-book{float:left}.rst-versions .rst-current-version.rst-out-of-date{background-color:#e74c3c;color:#fff}.rst-versions .rst-current-version.rst-active-old-version{background-color:#f1c40f;color:#000}.rst-versions.shift-up{height:auto;max-height:100%;overflow-y:scroll}.rst-versions.shift-up .rst-other-versions{display:block}.rst-versions .rst-other-versions{font-size:90%;padding:12px;color:grey;display:none}.rst-versions .rst-other-versions hr{display:block;height:1px;border:0;margin:20px 0;padding:0;border-top:1px solid #413d3d}.rst-versions .rst-other-versions dd{display:inline-block;margin:0}.rst-versions .rst-other-versions dd a{display:inline-block;padding:6px;color:#fcfcfc}.rst-versions.rst-badge{width:auto;bottom:20px;right:20px;left:auto;border:none;max-width:300px;max-height:90%}.rst-versions.rst-badge .fa-book,.rst-versions.rst-badge .icon-book{float:none;line-height:30px}.rst-versions.rst-badge.shift-up .rst-current-version{text-align:right}.rst-versions.rst-badge.shift-up .rst-current-version .fa-book,.rst-versions.rst-badge.shift-up .rst-current-version .icon-book{float:left}.rst-versions.rst-badge>.rst-current-version{width:auto;height:30px;line-height:30px;padding:0 6px;display:block;text-align:center}@media screen and (max-width:768px){.rst-versions{width:85%;display:none}.rst-versions.shift{display:block}}
.fa:before{-webkit-font-smoothing:antialiased}.clearfix{*zoom:1}.clearfix:after,.clearfix:before{display:table;content:""}.clearfix:after{clear:both}@font-face{font-family:FontAwesome;font-style:normal;font-weight:400;src:url(fonts/fontawesome-webfont.eot?674f50d287a8c48dc19ba404d20fe713?#iefix) format("embedded-opentype"),url(fonts/fontawesome-webfont.woff2?af7ae505a9eed503f8b8e6982036873e) format("woff2"),url(fonts/fontawesome-webfont.woff?fee66e712a8a08eef5805a46892932ad) format("woff"),url(fonts/fontawesome-webfont.ttf?b06871f281fee6b241d60582ae9369b9) format("truetype"),url(fonts/fontawesome-webfont.svg?912ec66d7572ff821749319396470bde#FontAwesome) format("svg")}.fa:before{font-family:FontAwesome;font-style:normal;font-weight:400;line-height:1}.fa:before,a .fa{text-decoration:inherit}.fa:before,a .fa,li .fa{display:inline-block}li .fa-large:before{width:1.875em}ul.fas{list-style-type:none;margin-left:2em;text-indent:-.8em}ul.fas li .fa{width:.8em}ul.fas li .fa-large:before{vertical-align:baseline}.fa-book:before,.icon-book:before{content:"\f02d"}.fa-caret-down:before,.icon-caret-down:before{content:"\f0d7"}.fa-caret-up:before,.icon-caret-up:before{content:"\f0d8"}.fa-caret-left:before,.icon-caret-left:before{content:"\f0d9"}.fa-caret-right:before,.icon-caret-right:before{content:"\f0da"}.rst-versions{position:fixed;bottom:0;left:0;width:300px;color:#fcfcfc;background:#1f1d1d;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;z-index:400}.rst-versions a{color:#2980b9;text-decoration:none}.rst-versions .rst-badge-small{display:none}.rst-versions .rst-current-version{padding:12px;background-color:#272525;display:block;text-align:right;font-size:90%;cursor:pointer;color:#27ae60}.rst-versions .rst-current-version:after{clear:both;content:"";display:block}.rst-versions .rst-current-version .fa{color:#fcfcfc}.rst-versions .rst-current-version .fa-book,.rst-versions .rst-current-version .icon-book{float:left}.rst-versions .rst-current-version.rst-out-of-date{background-color:#e74c3c;color:#fff}.rst-versions .rst-current-version.rst-active-old-version{background-color:#f1c40f;color:#000}.rst-versions.shift-up{height:auto;max-height:100%;overflow-y:scroll}.rst-versions.shift-up .rst-other-versions{display:block}.rst-versions .rst-other-versions{font-size:90%;padding:12px;color:grey;display:none}.rst-versions .rst-other-versions hr{display:block;height:1px;border:0;margin:20px 0;padding:0;border-top:1px solid #413d3d}.rst-versions .rst-other-versions dd{display:inline-block;margin:0}.rst-versions .rst-other-versions dd a{display:inline-block;padding:6px;color:#fcfcfc}.rst-versions.rst-badge{width:auto;bottom:20px;right:20px;left:auto;border:none;max-width:300px;max-height:90%}.rst-versions.rst-badge .fa-book,.rst-versions.rst-badge .icon-book{float:none;line-height:30px}.rst-versions.rst-badge.shift-up .rst-current-version{text-align:right}.rst-versions.rst-badge.shift-up .rst-current-version .fa-book,.rst-versions.rst-badge.shift-up .rst-current-version .icon-book{float:left}.rst-versions.rst-badge>.rst-current-version{width:auto;height:30px;line-height:30px;padding:0 6px;display:block;text-align:center}@media screen and (max-width:768px){.rst-versions{width:85%;display:none}.rst-versions.shift{display:block}}
File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 434 KiB

After

Width:  |  Height:  |  Size: 433 KiB

+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -9,4 +9,4 @@ var DOCUMENTATION_OPTIONS = {
HAS_SOURCE: true,
SOURCELINK_SUFFIX: '.txt',
NAVIGATION_WITH_KEYS: false
};
};
File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 434 KiB

After

Width:  |  Height:  |  Size: 433 KiB

+1 -1
View File
@@ -1 +1 @@
!function(e){var t={};function r(n){if(t[n])return t[n].exports;var o=t[n]={i:n,l:!1,exports:{}};return e[n].call(o.exports,o,o.exports,r),o.l=!0,o.exports}r.m=e,r.c=t,r.d=function(e,t,n){r.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:n})},r.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},r.t=function(e,t){if(1&t&&(e=r(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var n=Object.create(null);if(r.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)r.d(n,o,function(t){return e[t]}.bind(null,o));return n},r.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return r.d(t,"a",t),t},r.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},r.p="",r(r.s=4)}({4:function(e,t,r){}});
!function(e){var t={};function r(n){if(t[n])return t[n].exports;var o=t[n]={i:n,l:!1,exports:{}};return e[n].call(o.exports,o,o.exports,r),o.l=!0,o.exports}r.m=e,r.c=t,r.d=function(e,t,n){r.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:n})},r.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},r.t=function(e,t){if(1&t&&(e=r(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var n=Object.create(null);if(r.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)r.d(n,o,function(t){return e[t]}.bind(null,o));return n},r.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return r.d(t,"a",t),t},r.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},r.p="",r(r.s=4)}({4:function(e,t,r){}});
+1 -1
View File
@@ -1,4 +1,4 @@
/**
* @preserve HTML5 Shiv 3.7.3-pre | @afarkas @jdalton @jon_neal @rem | MIT/GPL2 Licensed
*/
!function(a,b){function c(a,b){var c=a.createElement("p"),d=a.getElementsByTagName("head")[0]||a.documentElement;return c.innerHTML="x<style>"+b+"</style>",d.insertBefore(c.lastChild,d.firstChild)}function d(){var a=y.elements;return"string"==typeof a?a.split(" "):a}function e(a,b){var c=y.elements;"string"!=typeof c&&(c=c.join(" ")),"string"!=typeof a&&(a=a.join(" ")),y.elements=c+" "+a,j(b)}function f(a){var b=x[a[v]];return b||(b={},w++,a[v]=w,x[w]=b),b}function g(a,c,d){if(c||(c=b),q)return c.createElement(a);d||(d=f(c));var e;return e=d.cache[a]?d.cache[a].cloneNode():u.test(a)?(d.cache[a]=d.createElem(a)).cloneNode():d.createElem(a),!e.canHaveChildren||t.test(a)||e.tagUrn?e:d.frag.appendChild(e)}function h(a,c){if(a||(a=b),q)return a.createDocumentFragment();c=c||f(a);for(var e=c.frag.cloneNode(),g=0,h=d(),i=h.length;i>g;g++)e.createElement(h[g]);return e}function i(a,b){b.cache||(b.cache={},b.createElem=a.createElement,b.createFrag=a.createDocumentFragment,b.frag=b.createFrag()),a.createElement=function(c){return y.shivMethods?g(c,a,b):b.createElem(c)},a.createDocumentFragment=Function("h,f","return function(){var n=f.cloneNode(),c=n.createElement;h.shivMethods&&("+d().join().replace(/[\w\-:]+/g,function(a){return b.createElem(a),b.frag.createElement(a),'c("'+a+'")'})+");return n}")(y,b.frag)}function j(a){a||(a=b);var d=f(a);return!y.shivCSS||p||d.hasCSS||(d.hasCSS=!!c(a,"article,aside,dialog,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}mark{background:#FF0;color:#000}template{display:none}")),q||i(a,d),a}function k(a){for(var b,c=a.getElementsByTagName("*"),e=c.length,f=RegExp("^(?:"+d().join("|")+")$","i"),g=[];e--;)b=c[e],f.test(b.nodeName)&&g.push(b.applyElement(l(b)));return g}function l(a){for(var b,c=a.attributes,d=c.length,e=a.ownerDocument.createElement(A+":"+a.nodeName);d--;)b=c[d],b.specified&&e.setAttribute(b.nodeName,b.nodeValue);return e.style.cssText=a.style.cssText,e}function m(a){for(var b,c=a.split("{"),e=c.length,f=RegExp("(^|[\\s,>+~])("+d().join("|")+")(?=[[\\s,>+~#.:]|$)","gi"),g="$1"+A+"\\:$2";e--;)b=c[e]=c[e].split("}"),b[b.length-1]=b[b.length-1].replace(f,g),c[e]=b.join("}");return c.join("{")}function n(a){for(var b=a.length;b--;)a[b].removeNode()}function o(a){function b(){clearTimeout(g._removeSheetTimer),d&&d.removeNode(!0),d=null}var d,e,g=f(a),h=a.namespaces,i=a.parentWindow;return!B||a.printShived?a:("undefined"==typeof h[A]&&h.add(A),i.attachEvent("onbeforeprint",function(){b();for(var f,g,h,i=a.styleSheets,j=[],l=i.length,n=Array(l);l--;)n[l]=i[l];for(;h=n.pop();)if(!h.disabled&&z.test(h.media)){try{f=h.imports,g=f.length}catch(o){g=0}for(l=0;g>l;l++)n.push(f[l]);try{j.push(h.cssText)}catch(o){}}j=m(j.reverse().join("")),e=k(a),d=c(a,j)}),i.attachEvent("onafterprint",function(){n(e),clearTimeout(g._removeSheetTimer),g._removeSheetTimer=setTimeout(b,500)}),a.printShived=!0,a)}var p,q,r="3.7.3",s=a.html5||{},t=/^<|^(?:button|map|select|textarea|object|iframe|option|optgroup)$/i,u=/^(?:a|b|code|div|fieldset|h1|h2|h3|h4|h5|h6|i|label|li|ol|p|q|span|strong|style|table|tbody|td|th|tr|ul)$/i,v="_html5shiv",w=0,x={};!function(){try{var a=b.createElement("a");a.innerHTML="<xyz></xyz>",p="hidden"in a,q=1==a.childNodes.length||function(){b.createElement("a");var a=b.createDocumentFragment();return"undefined"==typeof a.cloneNode||"undefined"==typeof a.createDocumentFragment||"undefined"==typeof a.createElement}()}catch(c){p=!0,q=!0}}();var y={elements:s.elements||"abbr article aside audio bdi canvas data datalist details dialog figcaption figure footer header hgroup main mark meter nav output picture progress section summary template time video",version:r,shivCSS:s.shivCSS!==!1,supportsUnknownElements:q,shivMethods:s.shivMethods!==!1,type:"default",shivDocument:j,createElement:g,createDocumentFragment:h,addElements:e};a.html5=y,j(b);var z=/^$|\b(?:all|print)\b/,A="html5shiv",B=!q&&function(){var c=b.documentElement;return!("undefined"==typeof b.namespaces||"undefined"==typeof b.parentWindow||"undefined"==typeof c.applyElement||"undefined"==typeof c.removeNode||"undefined"==typeof a.attachEvent)}();y.type+=" print",y.shivPrint=o,o(b),"object"==typeof module&&module.exports&&(module.exports=y)}("undefined"!=typeof window?window:this,document);
!function(a,b){function c(a,b){var c=a.createElement("p"),d=a.getElementsByTagName("head")[0]||a.documentElement;return c.innerHTML="x<style>"+b+"</style>",d.insertBefore(c.lastChild,d.firstChild)}function d(){var a=y.elements;return"string"==typeof a?a.split(" "):a}function e(a,b){var c=y.elements;"string"!=typeof c&&(c=c.join(" ")),"string"!=typeof a&&(a=a.join(" ")),y.elements=c+" "+a,j(b)}function f(a){var b=x[a[v]];return b||(b={},w++,a[v]=w,x[w]=b),b}function g(a,c,d){if(c||(c=b),q)return c.createElement(a);d||(d=f(c));var e;return e=d.cache[a]?d.cache[a].cloneNode():u.test(a)?(d.cache[a]=d.createElem(a)).cloneNode():d.createElem(a),!e.canHaveChildren||t.test(a)||e.tagUrn?e:d.frag.appendChild(e)}function h(a,c){if(a||(a=b),q)return a.createDocumentFragment();c=c||f(a);for(var e=c.frag.cloneNode(),g=0,h=d(),i=h.length;i>g;g++)e.createElement(h[g]);return e}function i(a,b){b.cache||(b.cache={},b.createElem=a.createElement,b.createFrag=a.createDocumentFragment,b.frag=b.createFrag()),a.createElement=function(c){return y.shivMethods?g(c,a,b):b.createElem(c)},a.createDocumentFragment=Function("h,f","return function(){var n=f.cloneNode(),c=n.createElement;h.shivMethods&&("+d().join().replace(/[\w\-:]+/g,function(a){return b.createElem(a),b.frag.createElement(a),'c("'+a+'")'})+");return n}")(y,b.frag)}function j(a){a||(a=b);var d=f(a);return!y.shivCSS||p||d.hasCSS||(d.hasCSS=!!c(a,"article,aside,dialog,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}mark{background:#FF0;color:#000}template{display:none}")),q||i(a,d),a}function k(a){for(var b,c=a.getElementsByTagName("*"),e=c.length,f=RegExp("^(?:"+d().join("|")+")$","i"),g=[];e--;)b=c[e],f.test(b.nodeName)&&g.push(b.applyElement(l(b)));return g}function l(a){for(var b,c=a.attributes,d=c.length,e=a.ownerDocument.createElement(A+":"+a.nodeName);d--;)b=c[d],b.specified&&e.setAttribute(b.nodeName,b.nodeValue);return e.style.cssText=a.style.cssText,e}function m(a){for(var b,c=a.split("{"),e=c.length,f=RegExp("(^|[\\s,>+~])("+d().join("|")+")(?=[[\\s,>+~#.:]|$)","gi"),g="$1"+A+"\\:$2";e--;)b=c[e]=c[e].split("}"),b[b.length-1]=b[b.length-1].replace(f,g),c[e]=b.join("}");return c.join("{")}function n(a){for(var b=a.length;b--;)a[b].removeNode()}function o(a){function b(){clearTimeout(g._removeSheetTimer),d&&d.removeNode(!0),d=null}var d,e,g=f(a),h=a.namespaces,i=a.parentWindow;return!B||a.printShived?a:("undefined"==typeof h[A]&&h.add(A),i.attachEvent("onbeforeprint",function(){b();for(var f,g,h,i=a.styleSheets,j=[],l=i.length,n=Array(l);l--;)n[l]=i[l];for(;h=n.pop();)if(!h.disabled&&z.test(h.media)){try{f=h.imports,g=f.length}catch(o){g=0}for(l=0;g>l;l++)n.push(f[l]);try{j.push(h.cssText)}catch(o){}}j=m(j.reverse().join("")),e=k(a),d=c(a,j)}),i.attachEvent("onafterprint",function(){n(e),clearTimeout(g._removeSheetTimer),g._removeSheetTimer=setTimeout(b,500)}),a.printShived=!0,a)}var p,q,r="3.7.3",s=a.html5||{},t=/^<|^(?:button|map|select|textarea|object|iframe|option|optgroup)$/i,u=/^(?:a|b|code|div|fieldset|h1|h2|h3|h4|h5|h6|i|label|li|ol|p|q|span|strong|style|table|tbody|td|th|tr|ul)$/i,v="_html5shiv",w=0,x={};!function(){try{var a=b.createElement("a");a.innerHTML="<xyz></xyz>",p="hidden"in a,q=1==a.childNodes.length||function(){b.createElement("a");var a=b.createDocumentFragment();return"undefined"==typeof a.cloneNode||"undefined"==typeof a.createDocumentFragment||"undefined"==typeof a.createElement}()}catch(c){p=!0,q=!0}}();var y={elements:s.elements||"abbr article aside audio bdi canvas data datalist details dialog figcaption figure footer header hgroup main mark meter nav output picture progress section summary template time video",version:r,shivCSS:s.shivCSS!==!1,supportsUnknownElements:q,shivMethods:s.shivMethods!==!1,type:"default",shivDocument:j,createElement:g,createDocumentFragment:h,addElements:e};a.html5=y,j(b);var z=/^$|\b(?:all|print)\b/,A="html5shiv",B=!q&&function(){var c=b.documentElement;return!("undefined"==typeof b.namespaces||"undefined"==typeof b.parentWindow||"undefined"==typeof c.applyElement||"undefined"==typeof c.removeNode||"undefined"==typeof a.attachEvent)}();y.type+=" print",y.shivPrint=o,o(b),"object"==typeof module&&module.exports&&(module.exports=y)}("undefined"!=typeof window?window:this,document);
+1 -1
View File
@@ -1,4 +1,4 @@
/**
* @preserve HTML5 Shiv 3.7.3 | @afarkas @jdalton @jon_neal @rem | MIT/GPL2 Licensed
*/
!function(a,b){function c(a,b){var c=a.createElement("p"),d=a.getElementsByTagName("head")[0]||a.documentElement;return c.innerHTML="x<style>"+b+"</style>",d.insertBefore(c.lastChild,d.firstChild)}function d(){var a=t.elements;return"string"==typeof a?a.split(" "):a}function e(a,b){var c=t.elements;"string"!=typeof c&&(c=c.join(" ")),"string"!=typeof a&&(a=a.join(" ")),t.elements=c+" "+a,j(b)}function f(a){var b=s[a[q]];return b||(b={},r++,a[q]=r,s[r]=b),b}function g(a,c,d){if(c||(c=b),l)return c.createElement(a);d||(d=f(c));var e;return e=d.cache[a]?d.cache[a].cloneNode():p.test(a)?(d.cache[a]=d.createElem(a)).cloneNode():d.createElem(a),!e.canHaveChildren||o.test(a)||e.tagUrn?e:d.frag.appendChild(e)}function h(a,c){if(a||(a=b),l)return a.createDocumentFragment();c=c||f(a);for(var e=c.frag.cloneNode(),g=0,h=d(),i=h.length;i>g;g++)e.createElement(h[g]);return e}function i(a,b){b.cache||(b.cache={},b.createElem=a.createElement,b.createFrag=a.createDocumentFragment,b.frag=b.createFrag()),a.createElement=function(c){return t.shivMethods?g(c,a,b):b.createElem(c)},a.createDocumentFragment=Function("h,f","return function(){var n=f.cloneNode(),c=n.createElement;h.shivMethods&&("+d().join().replace(/[\w\-:]+/g,function(a){return b.createElem(a),b.frag.createElement(a),'c("'+a+'")'})+");return n}")(t,b.frag)}function j(a){a||(a=b);var d=f(a);return!t.shivCSS||k||d.hasCSS||(d.hasCSS=!!c(a,"article,aside,dialog,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}mark{background:#FF0;color:#000}template{display:none}")),l||i(a,d),a}var k,l,m="3.7.3-pre",n=a.html5||{},o=/^<|^(?:button|map|select|textarea|object|iframe|option|optgroup)$/i,p=/^(?:a|b|code|div|fieldset|h1|h2|h3|h4|h5|h6|i|label|li|ol|p|q|span|strong|style|table|tbody|td|th|tr|ul)$/i,q="_html5shiv",r=0,s={};!function(){try{var a=b.createElement("a");a.innerHTML="<xyz></xyz>",k="hidden"in a,l=1==a.childNodes.length||function(){b.createElement("a");var a=b.createDocumentFragment();return"undefined"==typeof a.cloneNode||"undefined"==typeof a.createDocumentFragment||"undefined"==typeof a.createElement}()}catch(c){k=!0,l=!0}}();var t={elements:n.elements||"abbr article aside audio bdi canvas data datalist details dialog figcaption figure footer header hgroup main mark meter nav output picture progress section summary template time video",version:m,shivCSS:n.shivCSS!==!1,supportsUnknownElements:l,shivMethods:n.shivMethods!==!1,type:"default",shivDocument:j,createElement:g,createDocumentFragment:h,addElements:e};a.html5=t,j(b),"object"==typeof module&&module.exports&&(module.exports=t)}("undefined"!=typeof window?window:this,document);
!function(a,b){function c(a,b){var c=a.createElement("p"),d=a.getElementsByTagName("head")[0]||a.documentElement;return c.innerHTML="x<style>"+b+"</style>",d.insertBefore(c.lastChild,d.firstChild)}function d(){var a=t.elements;return"string"==typeof a?a.split(" "):a}function e(a,b){var c=t.elements;"string"!=typeof c&&(c=c.join(" ")),"string"!=typeof a&&(a=a.join(" ")),t.elements=c+" "+a,j(b)}function f(a){var b=s[a[q]];return b||(b={},r++,a[q]=r,s[r]=b),b}function g(a,c,d){if(c||(c=b),l)return c.createElement(a);d||(d=f(c));var e;return e=d.cache[a]?d.cache[a].cloneNode():p.test(a)?(d.cache[a]=d.createElem(a)).cloneNode():d.createElem(a),!e.canHaveChildren||o.test(a)||e.tagUrn?e:d.frag.appendChild(e)}function h(a,c){if(a||(a=b),l)return a.createDocumentFragment();c=c||f(a);for(var e=c.frag.cloneNode(),g=0,h=d(),i=h.length;i>g;g++)e.createElement(h[g]);return e}function i(a,b){b.cache||(b.cache={},b.createElem=a.createElement,b.createFrag=a.createDocumentFragment,b.frag=b.createFrag()),a.createElement=function(c){return t.shivMethods?g(c,a,b):b.createElem(c)},a.createDocumentFragment=Function("h,f","return function(){var n=f.cloneNode(),c=n.createElement;h.shivMethods&&("+d().join().replace(/[\w\-:]+/g,function(a){return b.createElem(a),b.frag.createElement(a),'c("'+a+'")'})+");return n}")(t,b.frag)}function j(a){a||(a=b);var d=f(a);return!t.shivCSS||k||d.hasCSS||(d.hasCSS=!!c(a,"article,aside,dialog,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}mark{background:#FF0;color:#000}template{display:none}")),l||i(a,d),a}var k,l,m="3.7.3-pre",n=a.html5||{},o=/^<|^(?:button|map|select|textarea|object|iframe|option|optgroup)$/i,p=/^(?:a|b|code|div|fieldset|h1|h2|h3|h4|h5|h6|i|label|li|ol|p|q|span|strong|style|table|tbody|td|th|tr|ul)$/i,q="_html5shiv",r=0,s={};!function(){try{var a=b.createElement("a");a.innerHTML="<xyz></xyz>",k="hidden"in a,l=1==a.childNodes.length||function(){b.createElement("a");var a=b.createDocumentFragment();return"undefined"==typeof a.cloneNode||"undefined"==typeof a.createDocumentFragment||"undefined"==typeof a.createElement}()}catch(c){k=!0,l=!0}}();var t={elements:n.elements||"abbr article aside audio bdi canvas data datalist details dialog figcaption figure footer header hgroup main mark meter nav output picture progress section summary template time video",version:m,shivCSS:n.shivCSS!==!1,supportsUnknownElements:l,shivMethods:n.shivMethods!==!1,type:"default",shivDocument:j,createElement:g,createDocumentFragment:h,addElements:e};a.html5=t,j(b),"object"==typeof module&&module.exports&&(module.exports=t)}("undefined"!=typeof window?window:this,document);
+1 -1
View File
@@ -1 +1 @@
!function(n){var e={};function t(i){if(e[i])return e[i].exports;var o=e[i]={i:i,l:!1,exports:{}};return n[i].call(o.exports,o,o.exports,t),o.l=!0,o.exports}t.m=n,t.c=e,t.d=function(n,e,i){t.o(n,e)||Object.defineProperty(n,e,{enumerable:!0,get:i})},t.r=function(n){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(n,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(n,"__esModule",{value:!0})},t.t=function(n,e){if(1&e&&(n=t(n)),8&e)return n;if(4&e&&"object"==typeof n&&n&&n.__esModule)return n;var i=Object.create(null);if(t.r(i),Object.defineProperty(i,"default",{enumerable:!0,value:n}),2&e&&"string"!=typeof n)for(var o in n)t.d(i,o,function(e){return n[e]}.bind(null,o));return i},t.n=function(n){var e=n&&n.__esModule?function(){return n.default}:function(){return n};return t.d(e,"a",e),e},t.o=function(n,e){return Object.prototype.hasOwnProperty.call(n,e)},t.p="",t(t.s=0)}([function(n,e,t){t(1),n.exports=t(3)},function(n,e,t){(function(){var e="undefined"!=typeof window?window.jQuery:t(2);n.exports.ThemeNav={navBar:null,win:null,winScroll:!1,winResize:!1,linkScroll:!1,winPosition:0,winHeight:null,docHeight:null,isRunning:!1,enable:function(n){var t=this;void 0===n&&(n=!0),t.isRunning||(t.isRunning=!0,e((function(e){t.init(e),t.reset(),t.win.on("hashchange",t.reset),n&&t.win.on("scroll",(function(){t.linkScroll||t.winScroll||(t.winScroll=!0,requestAnimationFrame((function(){t.onScroll()})))})),t.win.on("resize",(function(){t.winResize||(t.winResize=!0,requestAnimationFrame((function(){t.onResize()})))})),t.onResize()})))},enableSticky:function(){this.enable(!0)},init:function(n){n(document);var e=this;this.navBar=n("div.wy-side-scroll:first"),this.win=n(window),n(document).on("click","[data-toggle='wy-nav-top']",(function(){n("[data-toggle='wy-nav-shift']").toggleClass("shift"),n("[data-toggle='rst-versions']").toggleClass("shift")})).on("click",".wy-menu-vertical .current ul li a",(function(){var t=n(this);n("[data-toggle='wy-nav-shift']").removeClass("shift"),n("[data-toggle='rst-versions']").toggleClass("shift"),e.toggleCurrent(t),e.hashChange()})).on("click","[data-toggle='rst-current-version']",(function(){n("[data-toggle='rst-versions']").toggleClass("shift-up")})),n("table.docutils:not(.field-list,.footnote,.citation)").wrap("<div class='wy-table-responsive'></div>"),n("table.docutils.footnote").wrap("<div class='wy-table-responsive footnote'></div>"),n("table.docutils.citation").wrap("<div class='wy-table-responsive citation'></div>"),n(".wy-menu-vertical ul").not(".simple").siblings("a").each((function(){var t=n(this);expand=n('<span class="toctree-expand"></span>'),expand.on("click",(function(n){return e.toggleCurrent(t),n.stopPropagation(),!1})),t.prepend(expand)}))},reset:function(){var n=encodeURI(window.location.hash)||"#";try{var e=$(".wy-menu-vertical"),t=e.find('[href="'+n+'"]');if(0===t.length){var i=$('.document [id="'+n.substring(1)+'"]').closest("div.section");0===(t=e.find('[href="#'+i.attr("id")+'"]')).length&&(t=e.find('[href="#"]'))}t.length>0&&($(".wy-menu-vertical .current").removeClass("current"),t.addClass("current"),t.closest("li.toctree-l1").addClass("current"),t.closest("li.toctree-l1").parent().addClass("current"),t.closest("li.toctree-l1").addClass("current"),t.closest("li.toctree-l2").addClass("current"),t.closest("li.toctree-l3").addClass("current"),t.closest("li.toctree-l4").addClass("current"),t.closest("li.toctree-l5").addClass("current"),t[0].scrollIntoView())}catch(n){console.log("Error expanding nav for anchor",n)}},onScroll:function(){this.winScroll=!1;var n=this.win.scrollTop(),e=n+this.winHeight,t=this.navBar.scrollTop()+(n-this.winPosition);n<0||e>this.docHeight||(this.navBar.scrollTop(t),this.winPosition=n)},onResize:function(){this.winResize=!1,this.winHeight=this.win.height(),this.docHeight=$(document).height()},hashChange:function(){this.linkScroll=!0,this.win.one("hashchange",(function(){this.linkScroll=!1}))},toggleCurrent:function(n){var e=n.closest("li");e.siblings("li.current").removeClass("current"),e.siblings().find("li.current").removeClass("current"),e.find("> ul li.current").removeClass("current"),e.toggleClass("current")}},"undefined"!=typeof window&&(window.SphinxRtdTheme={Navigation:n.exports.ThemeNav,StickyNav:n.exports.ThemeNav}),function(){for(var n=0,e=["ms","moz","webkit","o"],t=0;t<e.length&&!window.requestAnimationFrame;++t)window.requestAnimationFrame=window[e[t]+"RequestAnimationFrame"],window.cancelAnimationFrame=window[e[t]+"CancelAnimationFrame"]||window[e[t]+"CancelRequestAnimationFrame"];window.requestAnimationFrame||(window.requestAnimationFrame=function(e,t){var i=(new Date).getTime(),o=Math.max(0,16-(i-n)),r=window.setTimeout((function(){e(i+o)}),o);return n=i+o,r}),window.cancelAnimationFrame||(window.cancelAnimationFrame=function(n){clearTimeout(n)})}()}).call(window)},function(n,e){n.exports=jQuery},function(n,e,t){}]);
!function(n){var e={};function t(i){if(e[i])return e[i].exports;var o=e[i]={i:i,l:!1,exports:{}};return n[i].call(o.exports,o,o.exports,t),o.l=!0,o.exports}t.m=n,t.c=e,t.d=function(n,e,i){t.o(n,e)||Object.defineProperty(n,e,{enumerable:!0,get:i})},t.r=function(n){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(n,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(n,"__esModule",{value:!0})},t.t=function(n,e){if(1&e&&(n=t(n)),8&e)return n;if(4&e&&"object"==typeof n&&n&&n.__esModule)return n;var i=Object.create(null);if(t.r(i),Object.defineProperty(i,"default",{enumerable:!0,value:n}),2&e&&"string"!=typeof n)for(var o in n)t.d(i,o,function(e){return n[e]}.bind(null,o));return i},t.n=function(n){var e=n&&n.__esModule?function(){return n.default}:function(){return n};return t.d(e,"a",e),e},t.o=function(n,e){return Object.prototype.hasOwnProperty.call(n,e)},t.p="",t(t.s=0)}([function(n,e,t){t(1),n.exports=t(3)},function(n,e,t){(function(){var e="undefined"!=typeof window?window.jQuery:t(2);n.exports.ThemeNav={navBar:null,win:null,winScroll:!1,winResize:!1,linkScroll:!1,winPosition:0,winHeight:null,docHeight:null,isRunning:!1,enable:function(n){var t=this;void 0===n&&(n=!0),t.isRunning||(t.isRunning=!0,e((function(e){t.init(e),t.reset(),t.win.on("hashchange",t.reset),n&&t.win.on("scroll",(function(){t.linkScroll||t.winScroll||(t.winScroll=!0,requestAnimationFrame((function(){t.onScroll()})))})),t.win.on("resize",(function(){t.winResize||(t.winResize=!0,requestAnimationFrame((function(){t.onResize()})))})),t.onResize()})))},enableSticky:function(){this.enable(!0)},init:function(n){n(document);var e=this;this.navBar=n("div.wy-side-scroll:first"),this.win=n(window),n(document).on("click","[data-toggle='wy-nav-top']",(function(){n("[data-toggle='wy-nav-shift']").toggleClass("shift"),n("[data-toggle='rst-versions']").toggleClass("shift")})).on("click",".wy-menu-vertical .current ul li a",(function(){var t=n(this);n("[data-toggle='wy-nav-shift']").removeClass("shift"),n("[data-toggle='rst-versions']").toggleClass("shift"),e.toggleCurrent(t),e.hashChange()})).on("click","[data-toggle='rst-current-version']",(function(){n("[data-toggle='rst-versions']").toggleClass("shift-up")})),n("table.docutils:not(.field-list,.footnote,.citation)").wrap("<div class='wy-table-responsive'></div>"),n("table.docutils.footnote").wrap("<div class='wy-table-responsive footnote'></div>"),n("table.docutils.citation").wrap("<div class='wy-table-responsive citation'></div>"),n(".wy-menu-vertical ul").not(".simple").siblings("a").each((function(){var t=n(this);expand=n('<span class="toctree-expand"></span>'),expand.on("click",(function(n){return e.toggleCurrent(t),n.stopPropagation(),!1})),t.prepend(expand)}))},reset:function(){var n=encodeURI(window.location.hash)||"#";try{var e=$(".wy-menu-vertical"),t=e.find('[href="'+n+'"]');if(0===t.length){var i=$('.document [id="'+n.substring(1)+'"]').closest("div.section");0===(t=e.find('[href="#'+i.attr("id")+'"]')).length&&(t=e.find('[href="#"]'))}t.length>0&&($(".wy-menu-vertical .current").removeClass("current"),t.addClass("current"),t.closest("li.toctree-l1").addClass("current"),t.closest("li.toctree-l1").parent().addClass("current"),t.closest("li.toctree-l1").addClass("current"),t.closest("li.toctree-l2").addClass("current"),t.closest("li.toctree-l3").addClass("current"),t.closest("li.toctree-l4").addClass("current"),t.closest("li.toctree-l5").addClass("current"),t[0].scrollIntoView())}catch(n){console.log("Error expanding nav for anchor",n)}},onScroll:function(){this.winScroll=!1;var n=this.win.scrollTop(),e=n+this.winHeight,t=this.navBar.scrollTop()+(n-this.winPosition);n<0||e>this.docHeight||(this.navBar.scrollTop(t),this.winPosition=n)},onResize:function(){this.winResize=!1,this.winHeight=this.win.height(),this.docHeight=$(document).height()},hashChange:function(){this.linkScroll=!0,this.win.one("hashchange",(function(){this.linkScroll=!1}))},toggleCurrent:function(n){var e=n.closest("li");e.siblings("li.current").removeClass("current"),e.siblings().find("li.current").removeClass("current"),e.find("> ul li.current").removeClass("current"),e.toggleClass("current")}},"undefined"!=typeof window&&(window.SphinxRtdTheme={Navigation:n.exports.ThemeNav,StickyNav:n.exports.ThemeNav}),function(){for(var n=0,e=["ms","moz","webkit","o"],t=0;t<e.length&&!window.requestAnimationFrame;++t)window.requestAnimationFrame=window[e[t]+"RequestAnimationFrame"],window.cancelAnimationFrame=window[e[t]+"CancelAnimationFrame"]||window[e[t]+"CancelRequestAnimationFrame"];window.requestAnimationFrame||(window.requestAnimationFrame=function(e,t){var i=(new Date).getTime(),o=Math.max(0,16-(i-n)),r=window.setTimeout((function(){e(i+o)}),o);return n=i+o,r}),window.cancelAnimationFrame||(window.cancelAnimationFrame=function(n){clearTimeout(n)})}()}).call(window)},function(n,e){n.exports=jQuery},function(n,e,t){}]);
+1 -3
View File
@@ -13,7 +13,7 @@
var stopwords = ["a","and","are","as","at","be","but","by","for","if","in","into","is","it","near","no","not","of","on","or","such","that","the","their","then","there","these","they","this","to","was","will","with"];
/* Non-minified version JS is _stemmer.js if file is provided */
/* Non-minified version JS is _stemmer.js if file is provided */
/**
* Porter Stemmer
*/
@@ -293,5 +293,3 @@ function splitQuery(query) {
}
return result;
}
+1 -1
View File
@@ -71,4 +71,4 @@ span.linenos.special { color: #000000; background-color: #ffffc0; padding-left:
.highlight .vg { color: #bb60d5 } /* Name.Variable.Global */
.highlight .vi { color: #bb60d5 } /* Name.Variable.Instance */
.highlight .vm { color: #bb60d5 } /* Name.Variable.Magic */
.highlight .il { color: #208050 } /* Literal.Number.Integer.Long */
.highlight .il { color: #208050 } /* Literal.Number.Integer.Long */
+70 -70
View File
@@ -9,75 +9,75 @@
<meta content="Documentation, TISBackup, configuration, backup jobs" name="keywords" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Configuring the backup jobs &mdash; TISBackup 1.8.2 documentation</title>
<title>Configuring the backup jobs &mdash; TISBackup 1.8.2 documentation</title>
<link rel="stylesheet" href="_static/css/theme.css" type="text/css" />
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/css/custom.css" type="text/css" />
<link rel="stylesheet" href="_static/css/ribbon.css" type="text/css" />
<link rel="stylesheet" href="_static/theme_overrides.css" type="text/css" />
<link rel="shortcut icon" href="_static/favicon.ico"/>
<!--[if lt IE 9]>
<script src="_static/js/html5shiv.min.js"></script>
<![endif]-->
<script type="text/javascript" id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>
<script src="_static/jquery.js"></script>
<script src="_static/underscore.js"></script>
<script src="_static/doctools.js"></script>
<script src="_static/language_data.js"></script>
<script type="text/javascript" src="_static/js/theme.js"></script>
<link rel="index" title="Index" href="genindex.html" />
<link rel="search" title="Search" href="search.html" />
<link rel="next" title="Using TISBackup" href="using_tisbackup.html" />
<link rel="prev" title="Installing and configuring TISBackup on Debian" href="installing_tisbackup.html" />
<link rel="prev" title="Installing and configuring TISBackup on Debian" href="installing_tisbackup.html" />
</head>
<body class="wy-body-for-nav">
<div class="wy-grid-for-nav">
<nav data-toggle="wy-nav-shift" class="wy-nav-side">
<div class="wy-side-scroll">
<div class="wy-side-nav-search" >
<a href="index.html" class="icon icon-home"> TISBackup
</a>
<div class="version">
1.8
</div>
<div role="search">
<form id="rtd-search-form" class="wy-form" action="search.html" method="get">
<input type="text" name="q" placeholder="Search docs" />
@@ -86,17 +86,17 @@
</form>
</div>
</div>
<div class="wy-menu wy-menu-vertical" data-spy="affix" role="navigation" aria-label="main navigation">
<p><span class="caption-text">Presenting TISBackup</span></p>
<ul class="current">
<li class="toctree-l1"><a class="reference internal" href="presenting_tisbackup.html">Technical background for TISBackup</a></li>
@@ -125,29 +125,29 @@
<li class="toctree-l1"><a class="reference internal" href="screenshots.html">Screenshots of TISBackup</a></li>
</ul>
</div>
</div>
</nav>
<section data-toggle="wy-nav-shift" class="wy-nav-content-wrap">
<nav class="wy-nav-top" aria-label="top navigation">
<i data-toggle="wy-nav-top" class="fa fa-bars"></i>
<a href="index.html">TISBackup</a>
</nav>
<div class="wy-nav-content">
<div class="rst-content">
@@ -168,28 +168,28 @@
<div role="navigation" aria-label="breadcrumbs navigation">
<ul class="wy-breadcrumbs">
<li><a href="index.html" class="icon icon-home"></a> &raquo;</li>
<li>Configuring the backup jobs</li>
<li class="wy-breadcrumbs-aside">
<a href="_sources/configuring_tisbackup.rst.txt" rel="nofollow"> View page source</a>
</li>
</ul>
<hr/>
</div>
<div role="main" class="document" itemscope="itemscope" itemtype="http://schema.org/Article">
<div itemprop="articleBody">
<section id="configuring-the-backup-jobs">
<h1>Configuring the backup jobs<a class="headerlink" href="#configuring-the-backup-jobs" title="Permalink to this headline"></a></h1>
<p id="configuring-backup-jobs">The configuration of the backups is done in an <em class="mimetype">.ini</em> file,
@@ -440,7 +440,7 @@ with read-write access only for it.</p>
</div>
</div>
<footer>
<div class="rst-footer-buttons" role="navigation" aria-label="footer navigation">
@@ -456,14 +456,14 @@ with read-write access only for it.</p>
</p>
</div>
Built with <a href="https://www.sphinx-doc.org/">Sphinx</a> using a
<a href="https://github.com/readthedocs/sphinx_rtd_theme">theme</a>
provided by <a href="https://readthedocs.org">Read the Docs</a>.
provided by <a href="https://readthedocs.org">Read the Docs</a>.
</footer>
</div>
@@ -472,7 +472,7 @@ with read-write access only for it.</p>
</section>
</div>
<script type="text/javascript">
jQuery(function () {
@@ -480,11 +480,11 @@ with read-write access only for it.</p>
});
</script>
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-89790248-2"></script>
@@ -499,4 +499,4 @@ gtag('config', 'UA-89790248-2');
</body>
</html>
</html>
+72 -72
View File
@@ -5,75 +5,75 @@
<html class="writer-html5" lang="en" >
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Index &mdash; TISBackup 1.8.2 documentation</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Index &mdash; TISBackup 1.8.2 documentation</title>
<link rel="stylesheet" href="_static/css/theme.css" type="text/css" />
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/css/custom.css" type="text/css" />
<link rel="stylesheet" href="_static/css/ribbon.css" type="text/css" />
<link rel="stylesheet" href="_static/theme_overrides.css" type="text/css" />
<link rel="shortcut icon" href="_static/favicon.ico"/>
<!--[if lt IE 9]>
<script src="_static/js/html5shiv.min.js"></script>
<![endif]-->
<script type="text/javascript" id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>
<script src="_static/jquery.js"></script>
<script src="_static/underscore.js"></script>
<script src="_static/doctools.js"></script>
<script src="_static/language_data.js"></script>
<script type="text/javascript" src="_static/js/theme.js"></script>
<link rel="index" title="Index" href="#" />
<link rel="search" title="Search" href="search.html" />
<link rel="search" title="Search" href="search.html" />
</head>
<body class="wy-body-for-nav">
<div class="wy-grid-for-nav">
<nav data-toggle="wy-nav-shift" class="wy-nav-side">
<div class="wy-side-scroll">
<div class="wy-side-nav-search" >
<a href="index.html" class="icon icon-home"> TISBackup
</a>
<div class="version">
1.8
</div>
<div role="search">
<form id="rtd-search-form" class="wy-form" action="search.html" method="get">
<input type="text" name="q" placeholder="Search docs" />
@@ -82,17 +82,17 @@
</form>
</div>
</div>
<div class="wy-menu wy-menu-vertical" data-spy="affix" role="navigation" aria-label="main navigation">
<p><span class="caption-text">Presenting TISBackup</span></p>
<ul>
<li class="toctree-l1"><a class="reference internal" href="presenting_tisbackup.html">Technical background for TISBackup</a></li>
@@ -106,29 +106,29 @@
<li class="toctree-l1"><a class="reference internal" href="screenshots.html">Screenshots of TISBackup</a></li>
</ul>
</div>
</div>
</nav>
<section data-toggle="wy-nav-shift" class="wy-nav-content-wrap">
<nav class="wy-nav-top" aria-label="top navigation">
<i data-toggle="wy-nav-top" class="fa fa-bars"></i>
<a href="index.html">TISBackup</a>
</nav>
<div class="wy-nav-content">
<div class="rst-content">
@@ -149,36 +149,36 @@
<div role="navigation" aria-label="breadcrumbs navigation">
<ul class="wy-breadcrumbs">
<li><a href="index.html" class="icon icon-home"></a> &raquo;</li>
<li>Index</li>
<li class="wy-breadcrumbs-aside">
</li>
</ul>
<hr/>
</div>
<div role="main" class="document" itemscope="itemscope" itemtype="http://schema.org/Article">
<div itemprop="articleBody">
<h1 id="index">Index</h1>
<div class="genindex-jumpbox">
</div>
</div>
</div>
<footer>
@@ -190,14 +190,14 @@
</p>
</div>
Built with <a href="https://www.sphinx-doc.org/">Sphinx</a> using a
<a href="https://github.com/readthedocs/sphinx_rtd_theme">theme</a>
provided by <a href="https://readthedocs.org">Read the Docs</a>.
provided by <a href="https://readthedocs.org">Read the Docs</a>.
</footer>
</div>
@@ -206,7 +206,7 @@
</section>
</div>
<script type="text/javascript">
jQuery(function () {
@@ -214,11 +214,11 @@
});
</script>
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-89790248-2"></script>
@@ -233,4 +233,4 @@ gtag('config', 'UA-89790248-2');
</body>
</html>
</html>
+70 -70
View File
@@ -9,74 +9,74 @@
<meta content="Documentation, TISBackup, introduction, welcome page, Welcome" name="keywords" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Presenting TISBackup &mdash; TISBackup 1.8.2 documentation</title>
<title>Presenting TISBackup &mdash; TISBackup 1.8.2 documentation</title>
<link rel="stylesheet" href="_static/css/theme.css" type="text/css" />
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/css/custom.css" type="text/css" />
<link rel="stylesheet" href="_static/css/ribbon.css" type="text/css" />
<link rel="stylesheet" href="_static/theme_overrides.css" type="text/css" />
<link rel="shortcut icon" href="_static/favicon.ico"/>
<!--[if lt IE 9]>
<script src="_static/js/html5shiv.min.js"></script>
<![endif]-->
<script type="text/javascript" id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>
<script src="_static/jquery.js"></script>
<script src="_static/underscore.js"></script>
<script src="_static/doctools.js"></script>
<script src="_static/language_data.js"></script>
<script type="text/javascript" src="_static/js/theme.js"></script>
<link rel="index" title="Index" href="genindex.html" />
<link rel="search" title="Search" href="search.html" />
<link rel="next" title="Technical background for TISBackup" href="presenting_tisbackup.html" />
<link rel="next" title="Technical background for TISBackup" href="presenting_tisbackup.html" />
</head>
<body class="wy-body-for-nav">
<div class="wy-grid-for-nav">
<nav data-toggle="wy-nav-shift" class="wy-nav-side">
<div class="wy-side-scroll">
<div class="wy-side-nav-search" >
<a href="#" class="icon icon-home"> TISBackup
</a>
<div class="version">
1.8
</div>
<div role="search">
<form id="rtd-search-form" class="wy-form" action="search.html" method="get">
<input type="text" name="q" placeholder="Search docs" />
@@ -85,17 +85,17 @@
</form>
</div>
</div>
<div class="wy-menu wy-menu-vertical" data-spy="affix" role="navigation" aria-label="main navigation">
<p><span class="caption-text">Presenting TISBackup</span></p>
<ul>
<li class="toctree-l1"><a class="reference internal" href="presenting_tisbackup.html">Technical background for TISBackup</a></li>
@@ -109,29 +109,29 @@
<li class="toctree-l1"><a class="reference internal" href="screenshots.html">Screenshots of TISBackup</a></li>
</ul>
</div>
</div>
</nav>
<section data-toggle="wy-nav-shift" class="wy-nav-content-wrap">
<nav class="wy-nav-top" aria-label="top navigation">
<i data-toggle="wy-nav-top" class="fa fa-bars"></i>
<a href="#">TISBackup</a>
</nav>
<div class="wy-nav-content">
<div class="rst-content">
@@ -152,28 +152,28 @@
<div role="navigation" aria-label="breadcrumbs navigation">
<ul class="wy-breadcrumbs">
<li><a href="#" class="icon icon-home"></a> &raquo;</li>
<li>Presenting TISBackup</li>
<li class="wy-breadcrumbs-aside">
<a href="_sources/index.rst.txt" rel="nofollow"> View page source</a>
</li>
</ul>
<hr/>
</div>
<div role="main" class="document" itemscope="itemscope" itemtype="http://schema.org/Article">
<div itemprop="articleBody">
<figure class="align-center">
<a class="reference internal image-reference" href="_images/tisbackup_logo.png"><img alt="TISBackup Logo" src="_images/tisbackup_logo.png" style="width: 700.0px; height: 206.0px;" /></a>
</figure>
@@ -275,7 +275,7 @@ if there is a problem during the backup.</p></li>
</div>
</div>
<footer>
<div class="rst-footer-buttons" role="navigation" aria-label="footer navigation">
@@ -290,14 +290,14 @@ if there is a problem during the backup.</p></li>
</p>
</div>
Built with <a href="https://www.sphinx-doc.org/">Sphinx</a> using a
<a href="https://github.com/readthedocs/sphinx_rtd_theme">theme</a>
provided by <a href="https://readthedocs.org">Read the Docs</a>.
provided by <a href="https://readthedocs.org">Read the Docs</a>.
</footer>
</div>
@@ -306,7 +306,7 @@ if there is a problem during the backup.</p></li>
</section>
</div>
<script type="text/javascript">
jQuery(function () {
@@ -314,11 +314,11 @@ if there is a problem during the backup.</p></li>
});
</script>
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-89790248-2"></script>
@@ -333,4 +333,4 @@ gtag('config', 'UA-89790248-2');
</body>
</html>
</html>
+70 -70
View File
@@ -9,75 +9,75 @@
<meta content="Documentation, TISBackup, installation, configuration" name="keywords" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Installing and configuring TISBackup on Debian &mdash; TISBackup 1.8.2 documentation</title>
<title>Installing and configuring TISBackup on Debian &mdash; TISBackup 1.8.2 documentation</title>
<link rel="stylesheet" href="_static/css/theme.css" type="text/css" />
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/css/custom.css" type="text/css" />
<link rel="stylesheet" href="_static/css/ribbon.css" type="text/css" />
<link rel="stylesheet" href="_static/theme_overrides.css" type="text/css" />
<link rel="shortcut icon" href="_static/favicon.ico"/>
<!--[if lt IE 9]>
<script src="_static/js/html5shiv.min.js"></script>
<![endif]-->
<script type="text/javascript" id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>
<script src="_static/jquery.js"></script>
<script src="_static/underscore.js"></script>
<script src="_static/doctools.js"></script>
<script src="_static/language_data.js"></script>
<script type="text/javascript" src="_static/js/theme.js"></script>
<link rel="index" title="Index" href="genindex.html" />
<link rel="search" title="Search" href="search.html" />
<link rel="next" title="Configuring the backup jobs" href="configuring_tisbackup.html" />
<link rel="prev" title="Technical background for TISBackup" href="presenting_tisbackup.html" />
<link rel="prev" title="Technical background for TISBackup" href="presenting_tisbackup.html" />
</head>
<body class="wy-body-for-nav">
<div class="wy-grid-for-nav">
<nav data-toggle="wy-nav-shift" class="wy-nav-side">
<div class="wy-side-scroll">
<div class="wy-side-nav-search" >
<a href="index.html" class="icon icon-home"> TISBackup
</a>
<div class="version">
1.8
</div>
<div role="search">
<form id="rtd-search-form" class="wy-form" action="search.html" method="get">
<input type="text" name="q" placeholder="Search docs" />
@@ -86,17 +86,17 @@
</form>
</div>
</div>
<div class="wy-menu wy-menu-vertical" data-spy="affix" role="navigation" aria-label="main navigation">
<p><span class="caption-text">Presenting TISBackup</span></p>
<ul class="current">
<li class="toctree-l1"><a class="reference internal" href="presenting_tisbackup.html">Technical background for TISBackup</a></li>
@@ -123,29 +123,29 @@
<li class="toctree-l1"><a class="reference internal" href="screenshots.html">Screenshots of TISBackup</a></li>
</ul>
</div>
</div>
</nav>
<section data-toggle="wy-nav-shift" class="wy-nav-content-wrap">
<nav class="wy-nav-top" aria-label="top navigation">
<i data-toggle="wy-nav-top" class="fa fa-bars"></i>
<a href="index.html">TISBackup</a>
</nav>
<div class="wy-nav-content">
<div class="rst-content">
@@ -166,28 +166,28 @@
<div role="navigation" aria-label="breadcrumbs navigation">
<ul class="wy-breadcrumbs">
<li><a href="index.html" class="icon icon-home"></a> &raquo;</li>
<li>Installing and configuring TISBackup on Debian</li>
<li class="wy-breadcrumbs-aside">
<a href="_sources/installing_tisbackup.rst.txt" rel="nofollow"> View page source</a>
</li>
</ul>
<hr/>
</div>
<div role="main" class="document" itemscope="itemscope" itemtype="http://schema.org/Article">
<div itemprop="articleBody">
<section id="installing-and-configuring-tisbackup-on-debian">
<h1>Installing and configuring TISBackup on Debian<a class="headerlink" href="#installing-and-configuring-tisbackup-on-debian" title="Permalink to this headline"></a></h1>
<section id="setting-up-the-gnu-linux-debian-server">
@@ -423,7 +423,7 @@ of your TISBackup server on port 8080.</p>
</div>
</div>
<footer>
<div class="rst-footer-buttons" role="navigation" aria-label="footer navigation">
@@ -439,14 +439,14 @@ of your TISBackup server on port 8080.</p>
</p>
</div>
Built with <a href="https://www.sphinx-doc.org/">Sphinx</a> using a
<a href="https://github.com/readthedocs/sphinx_rtd_theme">theme</a>
provided by <a href="https://readthedocs.org">Read the Docs</a>.
provided by <a href="https://readthedocs.org">Read the Docs</a>.
</footer>
</div>
@@ -455,7 +455,7 @@ of your TISBackup server on port 8080.</p>
</section>
</div>
<script type="text/javascript">
jQuery(function () {
@@ -463,11 +463,11 @@ of your TISBackup server on port 8080.</p>
});
</script>
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-89790248-2"></script>
@@ -482,4 +482,4 @@ gtag('config', 'UA-89790248-2');
</body>
</html>
</html>
+70 -70
View File
@@ -9,75 +9,75 @@
<meta content="Documentation, TISBackup, technical background" name="keywords" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Technical background for TISBackup &mdash; TISBackup 1.8.2 documentation</title>
<title>Technical background for TISBackup &mdash; TISBackup 1.8.2 documentation</title>
<link rel="stylesheet" href="_static/css/theme.css" type="text/css" />
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/css/custom.css" type="text/css" />
<link rel="stylesheet" href="_static/css/ribbon.css" type="text/css" />
<link rel="stylesheet" href="_static/theme_overrides.css" type="text/css" />
<link rel="shortcut icon" href="_static/favicon.ico"/>
<!--[if lt IE 9]>
<script src="_static/js/html5shiv.min.js"></script>
<![endif]-->
<script type="text/javascript" id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>
<script src="_static/jquery.js"></script>
<script src="_static/underscore.js"></script>
<script src="_static/doctools.js"></script>
<script src="_static/language_data.js"></script>
<script type="text/javascript" src="_static/js/theme.js"></script>
<link rel="index" title="Index" href="genindex.html" />
<link rel="search" title="Search" href="search.html" />
<link rel="next" title="Installing and configuring TISBackup on Debian" href="installing_tisbackup.html" />
<link rel="prev" title="Presenting TISBackup" href="index.html" />
<link rel="prev" title="Presenting TISBackup" href="index.html" />
</head>
<body class="wy-body-for-nav">
<div class="wy-grid-for-nav">
<nav data-toggle="wy-nav-shift" class="wy-nav-side">
<div class="wy-side-scroll">
<div class="wy-side-nav-search" >
<a href="index.html" class="icon icon-home"> TISBackup
</a>
<div class="version">
1.8
</div>
<div role="search">
<form id="rtd-search-form" class="wy-form" action="search.html" method="get">
<input type="text" name="q" placeholder="Search docs" />
@@ -86,17 +86,17 @@
</form>
</div>
</div>
<div class="wy-menu wy-menu-vertical" data-spy="affix" role="navigation" aria-label="main navigation">
<p><span class="caption-text">Presenting TISBackup</span></p>
<ul class="current">
<li class="toctree-l1 current"><a class="current reference internal" href="#">Technical background for TISBackup</a><ul>
@@ -116,29 +116,29 @@
<li class="toctree-l1"><a class="reference internal" href="screenshots.html">Screenshots of TISBackup</a></li>
</ul>
</div>
</div>
</nav>
<section data-toggle="wy-nav-shift" class="wy-nav-content-wrap">
<nav class="wy-nav-top" aria-label="top navigation">
<i data-toggle="wy-nav-top" class="fa fa-bars"></i>
<a href="index.html">TISBackup</a>
</nav>
<div class="wy-nav-content">
<div class="rst-content">
@@ -159,28 +159,28 @@
<div role="navigation" aria-label="breadcrumbs navigation">
<ul class="wy-breadcrumbs">
<li><a href="index.html" class="icon icon-home"></a> &raquo;</li>
<li>Technical background for TISBackup</li>
<li class="wy-breadcrumbs-aside">
<a href="_sources/presenting_tisbackup.rst.txt" rel="nofollow"> View page source</a>
</li>
</ul>
<hr/>
</div>
<div role="main" class="document" itemscope="itemscope" itemtype="http://schema.org/Article">
<div itemprop="articleBody">
<section id="technical-background-for-tisbackup">
<h1>Technical background for TISBackup<a class="headerlink" href="#technical-background-for-tisbackup" title="Permalink to this headline"></a></h1>
<p>The deduplication of this solution is based on the hardlinks
@@ -274,7 +274,7 @@ and <a class="reference internal" href="installing_tisbackup.html#base-debian-se
</div>
</div>
<footer>
<div class="rst-footer-buttons" role="navigation" aria-label="footer navigation">
@@ -290,14 +290,14 @@ and <a class="reference internal" href="installing_tisbackup.html#base-debian-se
</p>
</div>
Built with <a href="https://www.sphinx-doc.org/">Sphinx</a> using a
<a href="https://github.com/readthedocs/sphinx_rtd_theme">theme</a>
provided by <a href="https://readthedocs.org">Read the Docs</a>.
provided by <a href="https://readthedocs.org">Read the Docs</a>.
</footer>
</div>
@@ -306,7 +306,7 @@ and <a class="reference internal" href="installing_tisbackup.html#base-debian-se
</section>
</div>
<script type="text/javascript">
jQuery(function () {
@@ -314,11 +314,11 @@ and <a class="reference internal" href="installing_tisbackup.html#base-debian-se
});
</script>
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-89790248-2"></script>
@@ -333,4 +333,4 @@ gtag('config', 'UA-89790248-2');
</body>
</html>
</html>
+70 -70
View File
@@ -9,74 +9,74 @@
<meta content="Documentation, TISBackup, screenshots" name="keywords" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Screenshots of TISBackup &mdash; TISBackup 1.8.2 documentation</title>
<title>Screenshots of TISBackup &mdash; TISBackup 1.8.2 documentation</title>
<link rel="stylesheet" href="_static/css/theme.css" type="text/css" />
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/css/custom.css" type="text/css" />
<link rel="stylesheet" href="_static/css/ribbon.css" type="text/css" />
<link rel="stylesheet" href="_static/theme_overrides.css" type="text/css" />
<link rel="shortcut icon" href="_static/favicon.ico"/>
<!--[if lt IE 9]>
<script src="_static/js/html5shiv.min.js"></script>
<![endif]-->
<script type="text/javascript" id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>
<script src="_static/jquery.js"></script>
<script src="_static/underscore.js"></script>
<script src="_static/doctools.js"></script>
<script src="_static/language_data.js"></script>
<script type="text/javascript" src="_static/js/theme.js"></script>
<link rel="index" title="Index" href="genindex.html" />
<link rel="search" title="Search" href="search.html" />
<link rel="prev" title="Contacting Tranquil IT" href="tranquil-it-contacts.html" />
<link rel="prev" title="Contacting Tranquil IT" href="tranquil-it-contacts.html" />
</head>
<body class="wy-body-for-nav">
<div class="wy-grid-for-nav">
<nav data-toggle="wy-nav-shift" class="wy-nav-side">
<div class="wy-side-scroll">
<div class="wy-side-nav-search" >
<a href="index.html" class="icon icon-home"> TISBackup
</a>
<div class="version">
1.8
</div>
<div role="search">
<form id="rtd-search-form" class="wy-form" action="search.html" method="get">
<input type="text" name="q" placeholder="Search docs" />
@@ -85,17 +85,17 @@
</form>
</div>
</div>
<div class="wy-menu wy-menu-vertical" data-spy="affix" role="navigation" aria-label="main navigation">
<p><span class="caption-text">Presenting TISBackup</span></p>
<ul>
<li class="toctree-l1"><a class="reference internal" href="presenting_tisbackup.html">Technical background for TISBackup</a></li>
@@ -109,29 +109,29 @@
<li class="toctree-l1 current"><a class="current reference internal" href="#">Screenshots of TISBackup</a></li>
</ul>
</div>
</div>
</nav>
<section data-toggle="wy-nav-shift" class="wy-nav-content-wrap">
<nav class="wy-nav-top" aria-label="top navigation">
<i data-toggle="wy-nav-top" class="fa fa-bars"></i>
<a href="index.html">TISBackup</a>
</nav>
<div class="wy-nav-content">
<div class="rst-content">
@@ -152,28 +152,28 @@
<div role="navigation" aria-label="breadcrumbs navigation">
<ul class="wy-breadcrumbs">
<li><a href="index.html" class="icon icon-home"></a> &raquo;</li>
<li>Screenshots of TISBackup</li>
<li class="wy-breadcrumbs-aside">
<a href="_sources/screenshots.rst.txt" rel="nofollow"> View page source</a>
</li>
</ul>
<hr/>
</div>
<div role="main" class="document" itemscope="itemscope" itemtype="http://schema.org/Article">
<div itemprop="articleBody">
<section id="screenshots-of-tisbackup">
<h1>Screenshots of TISBackup<a class="headerlink" href="#screenshots-of-tisbackup" title="Permalink to this headline"></a></h1>
<figure class="align-center" id="id1">
@@ -222,7 +222,7 @@
</div>
</div>
<footer>
<div class="rst-footer-buttons" role="navigation" aria-label="footer navigation">
@@ -237,14 +237,14 @@
</p>
</div>
Built with <a href="https://www.sphinx-doc.org/">Sphinx</a> using a
<a href="https://github.com/readthedocs/sphinx_rtd_theme">theme</a>
provided by <a href="https://readthedocs.org">Read the Docs</a>.
provided by <a href="https://readthedocs.org">Read the Docs</a>.
</footer>
</div>
@@ -253,7 +253,7 @@
</section>
</div>
<script type="text/javascript">
jQuery(function () {
@@ -261,11 +261,11 @@
});
</script>
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-89790248-2"></script>
@@ -280,4 +280,4 @@ gtag('config', 'UA-89790248-2');
</body>
</html>
</html>
+74 -74
View File
@@ -4,78 +4,78 @@
<html class="writer-html5" lang="en" >
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Search &mdash; TISBackup 1.8.2 documentation</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Search &mdash; TISBackup 1.8.2 documentation</title>
<link rel="stylesheet" href="_static/css/theme.css" type="text/css" />
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/css/custom.css" type="text/css" />
<link rel="stylesheet" href="_static/css/ribbon.css" type="text/css" />
<link rel="stylesheet" href="_static/theme_overrides.css" type="text/css" />
<link rel="shortcut icon" href="_static/favicon.ico"/>
<!--[if lt IE 9]>
<script src="_static/js/html5shiv.min.js"></script>
<![endif]-->
<script type="text/javascript" id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>
<script src="_static/jquery.js"></script>
<script src="_static/underscore.js"></script>
<script src="_static/doctools.js"></script>
<script src="_static/language_data.js"></script>
<script type="text/javascript" src="_static/js/theme.js"></script>
<script type="text/javascript" src="_static/searchtools.js"></script>
<script type="text/javascript" src="_static/language_data.js"></script>
<link rel="index" title="Index" href="genindex.html" />
<link rel="search" title="Search" href="#" />
<link rel="search" title="Search" href="#" />
</head>
<body class="wy-body-for-nav">
<div class="wy-grid-for-nav">
<nav data-toggle="wy-nav-shift" class="wy-nav-side">
<div class="wy-side-scroll">
<div class="wy-side-nav-search" >
<a href="index.html" class="icon icon-home"> TISBackup
</a>
<div class="version">
1.8
</div>
<div role="search">
<form id="rtd-search-form" class="wy-form" action="#" method="get">
<input type="text" name="q" placeholder="Search docs" />
@@ -84,17 +84,17 @@
</form>
</div>
</div>
<div class="wy-menu wy-menu-vertical" data-spy="affix" role="navigation" aria-label="main navigation">
<p><span class="caption-text">Presenting TISBackup</span></p>
<ul>
<li class="toctree-l1"><a class="reference internal" href="presenting_tisbackup.html">Technical background for TISBackup</a></li>
@@ -108,29 +108,29 @@
<li class="toctree-l1"><a class="reference internal" href="screenshots.html">Screenshots of TISBackup</a></li>
</ul>
</div>
</div>
</nav>
<section data-toggle="wy-nav-shift" class="wy-nav-content-wrap">
<nav class="wy-nav-top" aria-label="top navigation">
<i data-toggle="wy-nav-top" class="fa fa-bars"></i>
<a href="index.html">TISBackup</a>
</nav>
<div class="wy-nav-content">
<div class="rst-content">
@@ -151,24 +151,24 @@
<div role="navigation" aria-label="breadcrumbs navigation">
<ul class="wy-breadcrumbs">
<li><a href="index.html" class="icon icon-home"></a> &raquo;</li>
<li>Search</li>
<li class="wy-breadcrumbs-aside">
</li>
</ul>
<hr/>
</div>
<div role="main" class="document" itemscope="itemscope" itemtype="http://schema.org/Article">
<div itemprop="articleBody">
<noscript>
<div id="fallback" class="admonition warning">
<p class="last">
@@ -177,13 +177,13 @@
</div>
</noscript>
<div id="search-results">
</div>
</div>
</div>
<footer>
@@ -195,14 +195,14 @@
</p>
</div>
Built with <a href="https://www.sphinx-doc.org/">Sphinx</a> using a
<a href="https://github.com/readthedocs/sphinx_rtd_theme">theme</a>
provided by <a href="https://readthedocs.org">Read the Docs</a>.
provided by <a href="https://readthedocs.org">Read the Docs</a>.
</footer>
</div>
@@ -211,7 +211,7 @@
</section>
</div>
<script type="text/javascript">
jQuery(function () {
@@ -219,17 +219,17 @@
});
</script>
<script type="text/javascript">
jQuery(function() { Search.loadIndex("searchindex.js"); });
</script>
<script type="text/javascript" id="searchindexloader"></script>
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-89790248-2"></script>
@@ -245,4 +245,4 @@ gtag('config', 'UA-89790248-2');
</body>
</html>
</html>
+1 -1
View File
File diff suppressed because one or more lines are too long
+70 -70
View File
@@ -9,75 +9,75 @@
<meta content="TISBackup, documentation, website, editor, Twitter, official website" name="keywords" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Contacting Tranquil IT &mdash; TISBackup 1.8.2 documentation</title>
<title>Contacting Tranquil IT &mdash; TISBackup 1.8.2 documentation</title>
<link rel="stylesheet" href="_static/css/theme.css" type="text/css" />
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/css/custom.css" type="text/css" />
<link rel="stylesheet" href="_static/css/ribbon.css" type="text/css" />
<link rel="stylesheet" href="_static/theme_overrides.css" type="text/css" />
<link rel="shortcut icon" href="_static/favicon.ico"/>
<!--[if lt IE 9]>
<script src="_static/js/html5shiv.min.js"></script>
<![endif]-->
<script type="text/javascript" id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>
<script src="_static/jquery.js"></script>
<script src="_static/underscore.js"></script>
<script src="_static/doctools.js"></script>
<script src="_static/language_data.js"></script>
<script type="text/javascript" src="_static/js/theme.js"></script>
<link rel="index" title="Index" href="genindex.html" />
<link rel="search" title="Search" href="search.html" />
<link rel="next" title="Screenshots of TISBackup" href="screenshots.html" />
<link rel="prev" title="Using TISBackup" href="using_tisbackup.html" />
<link rel="prev" title="Using TISBackup" href="using_tisbackup.html" />
</head>
<body class="wy-body-for-nav">
<div class="wy-grid-for-nav">
<nav data-toggle="wy-nav-shift" class="wy-nav-side">
<div class="wy-side-scroll">
<div class="wy-side-nav-search" >
<a href="index.html" class="icon icon-home"> TISBackup
</a>
<div class="version">
1.8
</div>
<div role="search">
<form id="rtd-search-form" class="wy-form" action="search.html" method="get">
<input type="text" name="q" placeholder="Search docs" />
@@ -86,17 +86,17 @@
</form>
</div>
</div>
<div class="wy-menu wy-menu-vertical" data-spy="affix" role="navigation" aria-label="main navigation">
<p><span class="caption-text">Presenting TISBackup</span></p>
<ul>
<li class="toctree-l1"><a class="reference internal" href="presenting_tisbackup.html">Technical background for TISBackup</a></li>
@@ -110,29 +110,29 @@
<li class="toctree-l1"><a class="reference internal" href="screenshots.html">Screenshots of TISBackup</a></li>
</ul>
</div>
</div>
</nav>
<section data-toggle="wy-nav-shift" class="wy-nav-content-wrap">
<nav class="wy-nav-top" aria-label="top navigation">
<i data-toggle="wy-nav-top" class="fa fa-bars"></i>
<a href="index.html">TISBackup</a>
</nav>
<div class="wy-nav-content">
<div class="rst-content">
@@ -153,28 +153,28 @@
<div role="navigation" aria-label="breadcrumbs navigation">
<ul class="wy-breadcrumbs">
<li><a href="index.html" class="icon icon-home"></a> &raquo;</li>
<li>Contacting Tranquil IT</li>
<li class="wy-breadcrumbs-aside">
<a href="_sources/tranquil-it-contacts.rst.txt" rel="nofollow"> View page source</a>
</li>
</ul>
<hr/>
</div>
<div role="main" class="document" itemscope="itemscope" itemtype="http://schema.org/Article">
<div itemprop="articleBody">
<section id="contacting-tranquil-it">
<span id="contact-tranquil-it"></span><h1>Contacting Tranquil IT<a class="headerlink" href="#contacting-tranquil-it" title="Permalink to this headline"></a></h1>
<ul class="simple">
@@ -185,7 +185,7 @@
</div>
</div>
<footer>
<div class="rst-footer-buttons" role="navigation" aria-label="footer navigation">
@@ -201,14 +201,14 @@
</p>
</div>
Built with <a href="https://www.sphinx-doc.org/">Sphinx</a> using a
<a href="https://github.com/readthedocs/sphinx_rtd_theme">theme</a>
provided by <a href="https://readthedocs.org">Read the Docs</a>.
provided by <a href="https://readthedocs.org">Read the Docs</a>.
</footer>
</div>
@@ -217,7 +217,7 @@
</section>
</div>
<script type="text/javascript">
jQuery(function () {
@@ -225,11 +225,11 @@
});
</script>
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-89790248-2"></script>
@@ -244,4 +244,4 @@ gtag('config', 'UA-89790248-2');
</body>
</html>
</html>
+70 -70
View File
@@ -9,75 +9,75 @@
<meta content="Documentation, TISBackup, usage, options, exporting" name="keywords" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Using TISBackup &mdash; TISBackup 1.8.2 documentation</title>
<title>Using TISBackup &mdash; TISBackup 1.8.2 documentation</title>
<link rel="stylesheet" href="_static/css/theme.css" type="text/css" />
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/css/custom.css" type="text/css" />
<link rel="stylesheet" href="_static/css/ribbon.css" type="text/css" />
<link rel="stylesheet" href="_static/theme_overrides.css" type="text/css" />
<link rel="shortcut icon" href="_static/favicon.ico"/>
<!--[if lt IE 9]>
<script src="_static/js/html5shiv.min.js"></script>
<![endif]-->
<script type="text/javascript" id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>
<script src="_static/jquery.js"></script>
<script src="_static/underscore.js"></script>
<script src="_static/doctools.js"></script>
<script src="_static/language_data.js"></script>
<script type="text/javascript" src="_static/js/theme.js"></script>
<link rel="index" title="Index" href="genindex.html" />
<link rel="search" title="Search" href="search.html" />
<link rel="next" title="Contacting Tranquil IT" href="tranquil-it-contacts.html" />
<link rel="prev" title="Configuring the backup jobs" href="configuring_tisbackup.html" />
<link rel="prev" title="Configuring the backup jobs" href="configuring_tisbackup.html" />
</head>
<body class="wy-body-for-nav">
<div class="wy-grid-for-nav">
<nav data-toggle="wy-nav-shift" class="wy-nav-side">
<div class="wy-side-scroll">
<div class="wy-side-nav-search" >
<a href="index.html" class="icon icon-home"> TISBackup
</a>
<div class="version">
1.8
</div>
<div role="search">
<form id="rtd-search-form" class="wy-form" action="search.html" method="get">
<input type="text" name="q" placeholder="Search docs" />
@@ -86,17 +86,17 @@
</form>
</div>
</div>
<div class="wy-menu wy-menu-vertical" data-spy="affix" role="navigation" aria-label="main navigation">
<p><span class="caption-text">Presenting TISBackup</span></p>
<ul class="current">
<li class="toctree-l1"><a class="reference internal" href="presenting_tisbackup.html">Technical background for TISBackup</a></li>
@@ -113,29 +113,29 @@
<li class="toctree-l1"><a class="reference internal" href="screenshots.html">Screenshots of TISBackup</a></li>
</ul>
</div>
</div>
</nav>
<section data-toggle="wy-nav-shift" class="wy-nav-content-wrap">
<nav class="wy-nav-top" aria-label="top navigation">
<i data-toggle="wy-nav-top" class="fa fa-bars"></i>
<a href="index.html">TISBackup</a>
</nav>
<div class="wy-nav-content">
<div class="rst-content">
@@ -156,28 +156,28 @@
<div role="navigation" aria-label="breadcrumbs navigation">
<ul class="wy-breadcrumbs">
<li><a href="index.html" class="icon icon-home"></a> &raquo;</li>
<li>Using TISBackup</li>
<li class="wy-breadcrumbs-aside">
<a href="_sources/using_tisbackup.rst.txt" rel="nofollow"> View page source</a>
</li>
</ul>
<hr/>
</div>
<div role="main" class="document" itemscope="itemscope" itemtype="http://schema.org/Article">
<div itemprop="articleBody">
<section id="using-tisbackup">
<h1>Using TISBackup<a class="headerlink" href="#using-tisbackup" title="Permalink to this headline"></a></h1>
<p id="id1">As seen in the <a class="reference internal" href="installing_tisbackup.html#install-tisbackup-debian"><span class="std std-ref">section on installing TISbackup</span></a>,
@@ -291,7 +291,7 @@ e2label /dev/xvdc1 tisbackup
</div>
</div>
<footer>
<div class="rst-footer-buttons" role="navigation" aria-label="footer navigation">
@@ -307,14 +307,14 @@ e2label /dev/xvdc1 tisbackup
</p>
</div>
Built with <a href="https://www.sphinx-doc.org/">Sphinx</a> using a
<a href="https://github.com/readthedocs/sphinx_rtd_theme">theme</a>
provided by <a href="https://readthedocs.org">Read the Docs</a>.
provided by <a href="https://readthedocs.org">Read the Docs</a>.
</footer>
</div>
@@ -323,7 +323,7 @@ e2label /dev/xvdc1 tisbackup
</section>
</div>
<script type="text/javascript">
jQuery(function () {
@@ -331,11 +331,11 @@ e2label /dev/xvdc1 tisbackup
});
</script>
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-89790248-2"></script>
@@ -350,4 +350,4 @@ gtag('config', 'UA-89790248-2');
</body>
</html>
</html>
Executable
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
env >> /etc/environment
# execute CMD
echo "$@"
exec "$@"
+73
View File
@@ -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",
]
+308
View File
@@ -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
+52
View File
@@ -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 {})
+74
View File
@@ -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
+78
View File
@@ -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
+121
View File
@@ -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)
+156
View File
@@ -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
+164
View File
@@ -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)
-179
View File
@@ -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)
-174
View File
@@ -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)
-164
View File
@@ -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)
-344
View File
@@ -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()))
-362
View File
@@ -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()))
-166
View File
@@ -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)
-158
View File
@@ -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)
-91
View File
@@ -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)
-277
View File
@@ -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)
+526
View File
@@ -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
-937
View File
@@ -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')
-295
View File
@@ -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)
+261
View File
@@ -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 six.moves.xmlrpc_client as xmlrpclib
import six.moves.http_client as httplib
import socket
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):
def __init__(self, details):
@@ -78,41 +80,48 @@ class Failure(Exception):
return msg
def _details_map(self):
return dict([(str(i), self.details[i])
for i in range(len(self.details))])
return dict([(str(i), self.details[i]) for i in range(len(self.details))])
# Just a "constant" that we use to decide whether to retry the RPC
_RECONNECT_AND_RETRY = object()
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):
path = self.host.replace("_", "/")
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.sock.connect(path)
class UDSHTTP(httplib.HTTPConnection):
_connection_class = UDSHTTPConnection
class UDSTransport(xmlrpclib.Transport):
def __init__(self, use_datetime=0):
self._use_datetime = use_datetime
self._extra_headers=[]
self._extra_headers = []
self._connection = (None, None)
def add_extra_header(self, key, value):
self._extra_headers += [ (key,value) ]
self._extra_headers += [(key, value)]
def make_connection(self, host):
# Python 2.4 compatibility
if sys.version_info[0] <= 2 and sys.version_info[1] < 7:
return UDSHTTP(host)
else:
return UDSHTTPConnection(host)
def send_request(self, connection, handler, request_body):
connection.putrequest("POST", handler)
for key, value in self._extra_headers:
connection.putheader(key, value)
class Session(xmlrpclib.ServerProxy):
"""A server proxy and session manager for communicating with xapi using
the Xen-API.
@@ -125,32 +134,27 @@ class Session(xmlrpclib.ServerProxy):
session.xenapi.session.logout()
"""
def __init__(self, uri, transport=None, encoding=None, verbose=0,
allow_none=1, ignore_ssl=False):
def __init__(self, uri, transport=None, encoding=None, verbose=0, allow_none=1, ignore_ssl=False):
# Fix for CA-172901 (+ Python 2.4 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 ) \
and ignore_ssl:
if not (sys.version_info[0] <= 2 and sys.version_info[1] <= 7 and sys.version_info[2] <= 9) and ignore_ssl:
import ssl
ctx = ssl._create_unverified_context()
xmlrpclib.ServerProxy.__init__(self, uri, transport, encoding,
verbose, allow_none, context=ctx)
xmlrpclib.ServerProxy.__init__(self, uri, transport, encoding, verbose, allow_none, context=ctx)
else:
xmlrpclib.ServerProxy.__init__(self, uri, transport, encoding,
verbose, allow_none)
xmlrpclib.ServerProxy.__init__(self, uri, transport, encoding, verbose, allow_none)
self.transport = transport
self._session = None
self.last_login_method = None
self.last_login_params = None
self.API_version = API_VERSION_1_1
def xenapi_request(self, methodname, params):
if methodname.startswith('login'):
if methodname.startswith("login"):
self._login(methodname, params)
return None
elif methodname == 'logout' or methodname == 'session.logout':
elif methodname == "logout" or methodname == "session.logout":
self._logout()
return None
else:
@@ -161,29 +165,25 @@ class Session(xmlrpclib.ServerProxy):
if result is _RECONNECT_AND_RETRY:
retry_count += 1
if self.last_login_method:
self._login(self.last_login_method,
self.last_login_params)
self._login(self.last_login_method, self.last_login_params)
else:
raise xmlrpclib.Fault(401, 'You must log in')
raise xmlrpclib.Fault(401, "You must log in")
else:
return result
raise xmlrpclib.Fault(
500, 'Tried 3 times to get a valid session, but failed')
raise xmlrpclib.Fault(500, "Tried 3 times to get a valid session, but failed")
def _login(self, method, params):
try:
result = _parse_result(
getattr(self, 'session.%s' % method)(*params))
result = _parse_result(getattr(self, "session.%s" % method)(*params))
if result is _RECONNECT_AND_RETRY:
raise xmlrpclib.Fault(
500, 'Received SESSION_INVALID when logging in')
raise xmlrpclib.Fault(500, "Received SESSION_INVALID when logging in")
self._session = result
self.last_login_method = method
self.last_login_params = params
self.API_version = self._get_api_version()
except socket.error as e:
if e.errno == socket.errno.ETIMEDOUT:
raise xmlrpclib.Fault(504, 'The connection timed out')
raise xmlrpclib.Fault(504, "The connection timed out")
else:
raise e
@@ -204,41 +204,41 @@ class Session(xmlrpclib.ServerProxy):
host = self.xenapi.pool.get_master(pool)
major = self.xenapi.host.get_API_version_major(host)
minor = self.xenapi.host.get_API_version_minor(host)
return "%s.%s"%(major,minor)
return "%s.%s" % (major, minor)
def __getattr__(self, name):
if name == 'handle':
if name == "handle":
return self._session
elif name == 'xenapi':
elif name == "xenapi":
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)
elif name == 'logout':
elif name == "logout":
return _Dispatcher(self.API_version, self.xenapi_request, "logout")
else:
return xmlrpclib.ServerProxy.__getattr__(self, name)
def xapi_local():
return Session("http://_var_lib_xcp_xapi/", transport=UDSTransport())
def _parse_result(result):
if type(result) != dict or 'Status' not in result:
raise xmlrpclib.Fault(500, 'Missing Status in response from server' + result)
if result['Status'] == 'Success':
if 'Value' in result:
return result['Value']
if not isinstance(type(result), dict) or "Status" not in result:
raise xmlrpclib.Fault(500, "Missing Status in response from server" + result)
if result["Status"] == "Success":
if "Value" in result:
return result["Value"]
else:
raise xmlrpclib.Fault(500,
'Missing Value in response from server')
raise xmlrpclib.Fault(500, "Missing Value in response from server")
else:
if 'ErrorDescription' in result:
if result['ErrorDescription'][0] == 'SESSION_INVALID':
if "ErrorDescription" in result:
if result["ErrorDescription"][0] == "SESSION_INVALID":
return _RECONNECT_AND_RETRY
else:
raise Failure(result['ErrorDescription'])
raise Failure(result["ErrorDescription"])
else:
raise xmlrpclib.Fault(
500, 'Missing ErrorDescription in response from server')
raise xmlrpclib.Fault(500, "Missing ErrorDescription in response from server")
# Based upon _Method from xmlrpclib.
@@ -250,9 +250,9 @@ class _Dispatcher:
def __repr__(self):
if self.__name:
return '<XenAPI._Dispatcher for %s>' % self.__name
return "<XenAPI._Dispatcher for %s>" % self.__name
else:
return '<XenAPI._Dispatcher>'
return "<XenAPI._Dispatcher>"
def __getattr__(self, name):
if self.__name is None:
+60
View File
@@ -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",
]
+195
View File
@@ -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)
+20 -13
View File
@@ -18,34 +18,41 @@
#
# -----------------------------------------------------------------------
import os
import datetime
from .common import *
import os
from libtisbackup import *
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"""
type = 'null'
required_params = ['type','server_name','backup_name']
type = "null"
required_params = ["type", "server_name", "backup_name"]
optional_params = []
def do_backup(self,stats):
def do_backup(self, stats):
pass
def process_backup(self):
pass
def cleanup_backup(self):
pass
def register_existingbackups(self):
pass
def export_latestbackup(self,destdir):
def export_latestbackup(self, destdir):
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)
if __name__=='__main__':
if __name__ == "__main__":
pass
+191
View File
@@ -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)
+173
View File
@@ -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)
+378
View File
@@ -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()))
+395
View File
@@ -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()))
+174
View File
@@ -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)
+171
View File
@@ -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 datetime
import logging
import os
import os.path
import re
import select
import socket
import requests
import pexpect
import time
import urllib.error
import urllib.parse
import urllib.request
from stat import *
import pexpect
import requests
from . import XenAPI
from libtisbackup import *
class backup_switch(backup_generic):
"""Backup a startup-config on a switch"""
type = 'switch'
required_params = backup_generic.required_params + ['switch_ip','switch_type']
optional_params = backup_generic.optional_params + [ 'switch_user', 'switch_password']
type = "switch"
switch_user = ''
switch_password = ''
required_params = backup_generic.required_params + ["switch_ip", "switch_type"]
optional_params = backup_generic.optional_params + ["switch_user", "switch_password"]
switch_user = ""
switch_password = ""
def switch_hp(self, filename):
s = socket.socket()
try:
s.connect((self.switch_ip, 23))
@@ -55,31 +58,31 @@ class backup_switch(backup_generic):
except:
raise
child=pexpect.spawn('telnet '+self.switch_ip)
child = pexpect.spawn("telnet " + self.switch_ip)
time.sleep(1)
if self.switch_user != "":
child.sendline(self.switch_user)
child.sendline(self.switch_password+'\r')
child.sendline(self.switch_password + "\r")
else:
child.sendline(self.switch_password+'\r')
child.sendline(self.switch_password + "\r")
try:
child.expect("#")
except:
raise Exception("Bad Credentials")
child.sendline( "terminal length 1000\r")
child.sendline("terminal length 1000\r")
child.expect("#")
child.sendline( "show config\r")
child.sendline("show config\r")
child.maxread = 100000000
child.expect("Startup.+$")
lines = child.after
if "-- MORE --" in lines:
if "-- MORE --" in lines:
raise Exception("Terminal lenght is not sufficient")
child.expect("#")
lines += child.before
child.sendline("logout\r")
child.send('y\r')
child.send("y\r")
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):
s = socket.socket()
@@ -89,38 +92,37 @@ class backup_switch(backup_generic):
except:
raise
child=pexpect.spawn('telnet '+self.switch_ip)
child = pexpect.spawn("telnet " + self.switch_ip)
time.sleep(1)
if self.switch_user:
child.sendline(self.switch_user)
child.expect('Password: ')
child.sendline(self.switch_password+'\r')
child.expect("Password: ")
child.sendline(self.switch_password + "\r")
try:
child.expect(">")
except:
raise Exception("Bad Credentials")
child.sendline('enable\r')
child.expect('Password: ')
child.sendline(self.switch_password+'\r')
child.sendline("enable\r")
child.expect("Password: ")
child.sendline(self.switch_password + "\r")
try:
child.expect("#")
except:
raise Exception("Bad Credentials")
child.sendline( "terminal length 0\r")
child.sendline("terminal length 0\r")
child.expect("#")
child.sendline("show run\r")
child.expect('Building configuration...')
child.expect("Building configuration...")
child.expect("#")
running_config = child.before
child.sendline("show vlan\r")
child.expect('VLAN')
child.expect("VLAN")
child.expect("#")
vlan = 'VLAN'+child.before
open(filename,"a").write(running_config+'\n'+vlan)
child.send('exit\r')
vlan = "VLAN" + child.before
open(filename, "a").write(running_config + "\n" + vlan)
child.send("exit\r")
child.close()
def switch_linksys_SRW2024(self, filename):
s = socket.socket()
try:
@@ -129,48 +131,53 @@ class backup_switch(backup_generic):
except:
raise
child=pexpect.spawn('telnet '+self.switch_ip)
child = pexpect.spawn("telnet " + self.switch_ip)
time.sleep(1)
if hasattr(self,'switch_password'):
child.sendline(self.switch_user+'\t')
child.sendline(self.switch_password+'\r')
if hasattr(self, "switch_password"):
child.sendline(self.switch_user + "\t")
child.sendline(self.switch_password + "\r")
else:
child.sendline(self.switch_user+'\r')
child.sendline(self.switch_user + "\r")
try:
child.expect('Menu')
child.expect("Menu")
except:
raise Exception("Bad Credentials")
child.sendline('\032')
child.expect('>')
child.sendline('lcli')
child.sendline("\032")
child.expect(">")
child.sendline("lcli")
child.expect("Name:")
if hasattr(self,'switch_password'):
child.send(self.switch_user+'\r'+self.switch_password+'\r')
if hasattr(self, "switch_password"):
child.send(self.switch_user + "\r" + self.switch_password + "\r")
else:
child.sendline(self.switch_user)
child.expect(".*#")
child.sendline( "terminal datadump")
child.sendline("terminal datadump")
child.expect("#")
child.sendline( "show startup-config")
child.sendline("show startup-config")
child.expect("#")
lines = child.before
if "Unrecognized command" in lines:
raise Exception("Bad Credentials")
child.sendline("exit")
#child.expect( ">")
#child.sendline("logout")
# child.expect( ">")
# child.sendline("logout")
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):
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})
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},
)
if "Wrong password" in resp.text:
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})
with open(filename, 'w') as f:
resp = requests.post(
"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)
def switch_dlink_DGS1510(self, filename):
@@ -181,12 +188,12 @@ class backup_switch(backup_generic):
except:
raise
child = pexpect.spawn('telnet ' + self.switch_ip)
child = pexpect.spawn("telnet " + self.switch_ip)
time.sleep(1)
if self.switch_user:
child.sendline(self.switch_user)
child.expect('Password:')
child.sendline(self.switch_password + '\r')
child.expect("Password:")
child.sendline(self.switch_password + "\r")
try:
child.expect("#")
except:
@@ -195,68 +202,66 @@ class backup_switch(backup_generic):
child.expect("#")
child.sendline("show run\r")
child.logfile_read = open(filename, "a")
child.expect('End of configuration file')
child.expect('#--')
child.expect("End of configuration file")
child.expect("#--")
child.expect("#")
child.close()
myre = re.compile("#--+")
myre = re.compile(r"#--+")
config = myre.split(open(filename).read())[2]
with open(filename,'w') as f:
with open(filename, "w") as f:
f.write(config)
def do_backup(self,stats):
def do_backup(self, stats):
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_params = " ".join(options)
# options = []
# options_params = " ".join(options)
if "LINKSYS-SRW2024" == self.switch_type:
dest_filename += '.txt'
dest_filename += ".txt"
self.switch_linksys_SRW2024(dest_filename)
elif self.switch_type in [ "CISCO", ]:
dest_filename += '.txt'
elif self.switch_type in [
"CISCO",
]:
dest_filename += ".txt"
self.switch_cisco(dest_filename)
elif self.switch_type in [ "HP-PROCURVE-4104GL", "HP-PROCURVE-2524" ]:
dest_filename += '.txt'
elif self.switch_type in ["HP-PROCURVE-4104GL", "HP-PROCURVE-2524"]:
dest_filename += ".txt"
self.switch_hp(dest_filename)
elif "DLINK-DGS1210" == self.switch_type:
dest_filename += '.bin'
dest_filename += ".bin"
self.switch_dlink_DGS1210(dest_filename)
elif "DLINK-DGS1510" == self.switch_type:
dest_filename += '.cfg'
dest_filename += ".cfg"
self.switch_dlink_DGS1510(dest_filename)
else:
raise Exception("Unknown Switch type")
stats['total_files_count']=1
stats['written_files_count']=1
stats['total_bytes']= os.stat(dest_filename).st_size
stats['written_bytes'] = stats['total_bytes']
stats['backup_location'] = dest_filename
stats['status']='OK'
stats['log']='Switch backup from %s OK, %d bytes written' % (self.server_name,stats['written_bytes'])
stats["total_files_count"] = 1
stats["written_files_count"] = 1
stats["total_bytes"] = os.stat(dest_filename).st_size
stats["written_bytes"] = stats["total_bytes"]
stats["backup_location"] = dest_filename
stats["status"] = "OK"
stats["log"] = "Switch backup from %s OK, %d bytes written" % (self.server_name, stats["written_bytes"])
except BaseException as e:
stats['status']='ERROR'
stats['log']=str(e)
stats["status"] = "ERROR"
stats["log"] = str(e)
raise
register_driver(backup_switch)
if __name__=='__main__':
logger = logging.getLogger('tisbackup')
if __name__ == "__main__":
logger = logging.getLogger("tisbackup")
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.setFormatter(formatter)
logger.addHandler(handler)
cp = ConfigParser()
cp.read('/opt/tisbackup/configtest.ini')
cp.read("/opt/tisbackup/configtest.ini")
b = backup_xva()
b.read_config(cp)
+114 -126
View File
@@ -18,217 +18,209 @@
#
# -----------------------------------------------------------------------
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 getpass
from datetime import date, datetime, timedelta
import pyVmomi
import requests
from pyVim.connect import Disconnect, SmartConnect
from pyVmomi import vim, vmodl
# Disable HTTPS verification warnings.
from requests.packages import urllib3
from libtisbackup import *
urllib3.disable_warnings()
import os
import time
import re
import tarfile
import re
import time
import xml.etree.ElementTree as ET
from stat import *
class backup_vmdk(backup_generic):
type = 'esx-vmdk'
type = "esx-vmdk"
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']
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"]
esx_port = 443
prefix_clone = "clone-"
create_ovafile = "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_value = client_cookie.split("=", 1)[1].split(";", 1)[0]
cookie_path = client_cookie.split("=", 1)[1].split(";", 1)[1].split(
";", 1)[0].lstrip()
cookie_path = client_cookie.split("=", 1)[1].split(";", 1)[1].split(";", 1)[0].lstrip()
cookie_text = " " + cookie_value + "; $" + cookie_path
# Make a cookie
cookie = dict()
cookie[cookie_name] = cookie_text
cookie[cookie_name] = cookie_text
return cookie
def download_file(self,url, local_filename, cookie, headers):
r = requests.get(url, stream=True, headers=headers,cookies=cookie,verify=False)
with open(local_filename, 'wb') as f:
for chunk in r.iter_content(chunk_size=1024*1024*64):
if chunk:
def download_file(self, url, local_filename, cookie, headers):
r = requests.get(url, stream=True, headers=headers, cookies=cookie, verify=False)
with open(local_filename, "wb") as f:
for chunk in r.iter_content(chunk_size=1024 * 1024 * 64):
if chunk:
f.write(chunk)
f.flush()
return local_filename
def export_vmdks(self,vm):
def export_vmdks(self, vm):
HttpNfcLease = vm.ExportVm()
try:
infos = HttpNfcLease.info
device_urls = infos.deviceUrl
vmdks = []
vmdks = []
for device_url in device_urls:
deviceId = device_url.key
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)
diskLocalPath = './' + diskFileName
# diskLocalPath = './' + diskFileName
cookie = self.make_compatible_cookie(si._stub.cookie)
headers = {'Content-Type': 'application/octet-stream'}
self.logger.debug("[%s] exporting disk: %s" %(self.server_name,diskFileName))
headers = {"Content-Type": "application/octet-stream"}
self.logger.debug("[%s] exporting disk: %s" % (self.server_name, diskFileName))
self.download_file(diskUrlStr, diskFileName, cookie, headers)
vmdks.append({"filename":diskFileName,"id":deviceId})
vmdks.append({"filename": diskFileName, "id": deviceId})
finally:
HttpNfcLease.Complete()
return vmdks
def create_ovf(self,vm,vmdks):
def create_ovf(self, vm, vmdks):
ovfDescParams = vim.OvfManager.CreateDescriptorParams()
ovf = si.content.ovfManager.CreateDescriptor(vm, ovfDescParams)
root = ET.fromstring(ovf.ovfDescriptor)
ovf = si.content.ovfManager.CreateDescriptor(vm, ovfDescParams)
root = ET.fromstring(ovf.ovfDescriptor)
new_id = list(root[0][1].attrib.values())[0][1:3]
ovfFiles = []
for vmdk in vmdks:
old_id = vmdk['id'][1:3]
id = vmdk['id'].replace(old_id,new_id)
ovfFiles.append(vim.OvfManager.OvfFile(size=os.path.getsize(vmdk['filename']), path=vmdk['filename'], deviceId=id))
old_id = vmdk["id"][1:3]
id = vmdk["id"].replace(old_id, new_id)
ovfFiles.append(vim.OvfManager.OvfFile(size=os.path.getsize(vmdk["filename"]), path=vmdk["filename"], deviceId=id))
ovfDescParams = vim.OvfManager.CreateDescriptorParams()
ovfDescParams.ovfFiles = ovfFiles;
ovfDescParams.ovfFiles = ovfFiles
ovf = si.content.ovfManager.CreateDescriptor(vm, ovfDescParams)
ovf_filename = vm.name+".ovf"
self.logger.debug("[%s] creating ovf file: %s" %(self.server_name,ovf_filename))
with open(ovf_filename, "w") as f:
f.write(ovf.ovfDescriptor)
ovf = si.content.ovfManager.CreateDescriptor(vm, ovfDescParams)
ovf_filename = vm.name + ".ovf"
self.logger.debug("[%s] creating ovf file: %s" % (self.server_name, ovf_filename))
with open(ovf_filename, "w") as f:
f.write(ovf.ovfDescriptor)
return ovf_filename
def create_ova(self,vm, vmdks, ovf_filename):
ova_filename = vm.name+".ova"
vmdks.insert(0,{"filename":ovf_filename,"id":"false"})
self.logger.debug("[%s] creating ova file: %s" %(self.server_name,ova_filename))
with tarfile.open(ova_filename, "w") as tar:
def create_ova(self, vm, vmdks, ovf_filename):
ova_filename = vm.name + ".ova"
vmdks.insert(0, {"filename": ovf_filename, "id": "false"})
self.logger.debug("[%s] creating ova file: %s" % (self.server_name, ova_filename))
with tarfile.open(ova_filename, "w") as tar:
for vmdk in vmdks:
tar.add(vmdk['filename'])
os.unlink(vmdk['filename'])
tar.add(vmdk["filename"])
os.unlink(vmdk["filename"])
return ova_filename
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))
snapshot=task.info.result
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
)
)
snapshot = task.info.result
prefix_vmclone = self.prefix_clone
clone_name = prefix_vmclone + vm.name
datastore = '[%s]' % vm.datastore[0].name
clone_name = prefix_vmclone + vm.name
datastore = "[%s]" % vm.datastore[0].name
vmx_file = vim.vm.FileInfo(logDirectory=None, snapshotDirectory=None, suspendDirectory=None, vmPathName=datastore)
vmx_file = vim.vm.FileInfo(logDirectory=None,
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)
config = vim.vm.ConfigSpec(
name=clone_name, memoryMB=vm.summary.config.memorySizeMB, numCPUs=vm.summary.config.numCpu, files=vmx_file
)
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]
controller = vim.vm.device.VirtualDeviceSpec()
controller = vim.vm.device.VirtualDeviceSpec()
controller.operation = vim.vm.device.VirtualDeviceSpec.Operation.add
controller.device = vim.vm.device.VirtualLsiLogicController(busNumber=0,sharedBus='noSharing')
controller.device.key = 0
i=0
controller.device = vim.vm.device.VirtualLsiLogicController(busNumber=0, sharedBus="noSharing")
controller.device.key = 0
i = 0
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:
if device.__class__.__name__ == 'vim.vm.device.VirtualDisk':
cur_vers = int(re.findall(r'\d{3,6}', device.backing.fileName)[0])
if device.__class__.__name__ == "vim.vm.device.VirtualDisk":
cur_vers = int(re.findall(r"\d{3,6}", device.backing.fileName)[0])
if cur_vers == 1:
source = device.backing.fileName.replace('-000001','')
source = device.backing.fileName.replace("-000001", "")
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]
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.ShrinkVirtualDisk_Task(dest))
dest = clone_folder + source.split("/")[-1]
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.ShrinkVirtualDisk_Task(dest))
diskfileBacking = vim.vm.device.VirtualDisk.FlatVer2BackingInfo()
diskfileBacking.fileName = dest
diskfileBacking.diskMode = "persistent"
diskfileBacking.diskMode = "persistent"
vdisk_spec = vim.vm.device.VirtualDeviceSpec()
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.backing = diskfileBacking
vdisk_spec.device.unitNumber = i
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(snapshot.RemoveSnapshot_Task(removeChildren=True))
return new_vm
def wait_task(self,task):
def wait_task(self, task):
while task.info.state in ["queued", "running"]:
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
def do_backup(self,stats):
def do_backup(self, stats):
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 self.dry_run:
os.makedirs(dest_dir)
else:
print('mkdir "%s"' % dest_dir)
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)
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
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:
raise Exception("Could not connect to the specified host using specified "
"username and password")
raise Exception("Could not connect to the specified host using specified " "username and password")
atexit.register(Disconnect, si)
content = si.RetrieveContent()
for child in content.rootFolder.childEntity:
if hasattr(child, 'vmFolder'):
if hasattr(child, "vmFolder"):
global vmFolder, datacenter
datacenter = child
vmFolder = datacenter.vmFolder
@@ -237,46 +229,42 @@ class backup_vmdk(backup_generic):
if vm.name == self.server_name:
vm_is_off = vm.summary.runtime.powerState == "poweredOff"
if str2bool(self.halt_vm):
vm.ShutdownGuest()
vm.ShutdownGuest()
vm_is_off = True
if vm_is_off:
if vm_is_off:
vmdks = self.export_vmdks(vm)
ovf_filename = self.create_ovf(vm, vmdks)
ovf_filename = self.create_ovf(vm, vmdks)
else:
new_vm = self.clone_vm(vm)
vmdks = self.export_vmdks(new_vm)
ovf_filename = self.create_ovf(vm, vmdks)
self.wait_task(new_vm.Destroy_Task())
self.wait_task(new_vm.Destroy_Task())
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):
vm.PowerOnVM()
vm.PowerOnVM()
if os.path.exists(dest_dir):
for file in os.listdir(dest_dir):
stats['written_bytes'] += os.stat(file)[ST_SIZE]
stats['total_files_count'] += 1
stats['written_files_count'] += 1
stats['total_bytes'] = stats['written_bytes']
for file in os.listdir(dest_dir):
stats["written_bytes"] += os.stat(file)[ST_SIZE]
stats["total_files_count"] += 1
stats["written_files_count"] += 1
stats["total_bytes"] = stats["written_bytes"]
else:
stats['written_bytes'] = 0
stats["written_bytes"] = 0
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["backup_location"] = dest_dir
stats["log"] = "XVA backup from %s OK, %d bytes written" % (self.server_name, stats["written_bytes"])
stats["status"] = "OK"
except BaseException as e:
stats['status']='ERROR'
stats['log']=str(e)
stats["status"] = "ERROR"
stats["log"] = str(e)
raise
register_driver(backup_vmdk)
+101
View File
@@ -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)
+309
View File
@@ -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)
+287
View File
@@ -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)
+26 -12
View File
@@ -8,18 +8,32 @@ from .config import BasicConfig, ConfigNamespace
from .compat import RawConfigParser, ConfigParser, SafeConfigParser
from .utils import tidy
from .configparser import DuplicateSectionError, \
NoSectionError, NoOptionError, \
InterpolationMissingOptionError, \
InterpolationDepthError, \
InterpolationSyntaxError, \
DEFAULTSECT, MAX_INTERPOLATION_DEPTH
from .configparser import (
DuplicateSectionError,
NoSectionError,
NoOptionError,
InterpolationMissingOptionError,
InterpolationDepthError,
InterpolationSyntaxError,
DEFAULTSECT,
MAX_INTERPOLATION_DEPTH,
)
__all__ = [
'BasicConfig', 'ConfigNamespace',
'INIConfig', 'tidy', 'change_comment_syntax',
'RawConfigParser', 'ConfigParser', 'SafeConfigParser',
'DuplicateSectionError', 'NoSectionError', 'NoOptionError',
'InterpolationMissingOptionError', 'InterpolationDepthError',
'InterpolationSyntaxError', 'DEFAULTSECT', 'MAX_INTERPOLATION_DEPTH',
"BasicConfig",
"ConfigNamespace",
"INIConfig",
"tidy",
"change_comment_syntax",
"RawConfigParser",
"ConfigParser",
"SafeConfigParser",
"DuplicateSectionError",
"NoSectionError",
"NoOptionError",
"InterpolationMissingOptionError",
"InterpolationDepthError",
"InterpolationSyntaxError",
"DEFAULTSECT",
"MAX_INTERPOLATION_DEPTH",
]
+89 -75
View File
@@ -12,44 +12,49 @@ The underlying INIConfig object can be accessed as cfg.data
"""
import re
from .configparser import DuplicateSectionError, \
NoSectionError, NoOptionError, \
InterpolationMissingOptionError, \
InterpolationDepthError, \
InterpolationSyntaxError, \
DEFAULTSECT, MAX_INTERPOLATION_DEPTH
from typing import Dict, List, TextIO, Optional, Type, Union, Tuple
# 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.
from .configparser import Error, InterpolationError, \
MissingSectionHeaderError, ParsingError
import six
from .configparser import Error, InterpolationError, MissingSectionHeaderError, ParsingError
from . import ini
class RawConfigParser(object):
class RawConfigParser:
def __init__(self, defaults=None, dict_type=dict):
if dict_type != dict:
raise ValueError('Custom dict types not supported')
if dict_type is not dict:
raise ValueError("Custom dict types not supported")
self.data = ini.INIConfig(defaults=defaults, optionxformsource=self)
def optionxform(self, optionstr):
def optionxform(self, optionstr: str) -> str:
return optionstr.lower()
def defaults(self):
d = {}
secobj = self.data._defaults
def defaults(self) -> Dict[str, str]:
d: Dict[str, str] = {}
secobj: ini.INISection = self.data._defaults
name: str
for name in secobj._options:
d[name] = secobj._compat_get(name)
return d
def sections(self):
def sections(self) -> List[str]:
"""Return a list of section names, excluding [DEFAULT]"""
return list(self.data)
def add_section(self, section):
def add_section(self, section: str) -> None:
"""Create a new section in the configuration.
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
# treatment - so it is special-cased here.
if section.lower() == "default":
raise ValueError('Invalid section name: %s' % section)
raise ValueError("Invalid section name: %s" % section)
if self.has_section(section):
raise DuplicateSectionError(section)
else:
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.
The DEFAULT section is not acknowledged.
"""
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."""
if section in self.data:
return list(self.data[section])
else:
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.
Files that cannot be opened are silently ignored; this is
@@ -89,9 +94,11 @@ class RawConfigParser(object):
home directory, systemwide directory), and all existing
configuration files in the list will be read. A single
filename may also be given.
Returns the list of files that were read.
"""
files_read = []
if isinstance(filenames, six.string_types):
if isinstance(filenames, str):
filenames = [filenames]
for filename in filenames:
try:
@@ -103,7 +110,7 @@ class RawConfigParser(object):
fp.close()
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.
The `fp' argument must have a `readline' method. Optional
@@ -113,60 +120,70 @@ class RawConfigParser(object):
"""
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):
raise NoSectionError(section)
sec = self.data[section]
sec: ini.INISection = self.data[section]
if option in sec:
return sec._compat_get(option)
else:
raise NoOptionError(option, section)
def items(self, section):
def items(self, section: str) -> List[Tuple[str, str]]:
if section in self.data:
ans = []
opt: str
for opt in self.data[section]:
ans.append((opt, self.get(section, opt)))
return ans
else:
raise NoSectionError(section)
def getint(self, section, option):
def getint(self, section: str, option: str) -> int:
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))
_boolean_states = {'1': True, 'yes': True, 'true': True, 'on': True,
'0': False, 'no': False, 'false': False, 'off': False}
_boolean_states = {
"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)
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()]
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."""
if section in self.data:
sec = self.data[section]
else:
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."""
if section in self.data:
self.data[section][option] = value
else:
raise NoSectionError(section)
def write(self, fp):
def write(self, fp: TextIO) -> None:
"""Write an .ini-format representation of the configuration state."""
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."""
if section in self.data:
sec = self.data[section]
@@ -178,7 +195,7 @@ class RawConfigParser(object):
else:
return 0
def remove_section(self, section):
def remove_section(self, section: str) -> bool:
"""Remove a file section."""
if not self.has_section(section):
return False
@@ -186,15 +203,15 @@ class RawConfigParser(object):
return True
class ConfigDict(object):
"""Present a dict interface to a ini section."""
class ConfigDict:
"""Present a dict interface to an ini section."""
def __init__(self, cfg, section, vars):
self.cfg = cfg
self.section = section
self.vars = vars
def __init__(self, cfg: RawConfigParser, section: str, vars: dict):
self.cfg: RawConfigParser = cfg
self.section: str = section
self.vars: dict = vars
def __getitem__(self, key):
def __getitem__(self, key: str) -> Union[str, List[Union[int, str]]]:
try:
return RawConfigParser.get(self.cfg, self.section, key, self.vars)
except (NoOptionError, NoSectionError):
@@ -202,8 +219,13 @@ class ConfigDict(object):
class ConfigParser(RawConfigParser):
def get(self, section, option, raw=False, vars=None):
def get(
self,
section: str,
option: str,
raw: bool = False,
vars: Optional[dict] = None,
) -> object:
"""Get an option value for a given section.
All % interpolations are expanded in the return values, based on the
@@ -226,25 +248,24 @@ class ConfigParser(RawConfigParser):
d = ConfigDict(self, section, vars)
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
value = rawval
depth = MAX_INTERPOLATION_DEPTH
while depth: # Loop through this until it's done
while depth: # Loop through this until it's done
depth -= 1
if "%(" in value:
try:
value = value % vars
except KeyError as e:
raise InterpolationMissingOptionError(
option, section, rawval, e.args[0])
raise InterpolationMissingOptionError(option, section, rawval, e.args[0])
else:
break
if value.find("%(") != -1:
raise InterpolationDepthError(option, section, rawval)
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
in the section.
@@ -272,40 +293,37 @@ class ConfigParser(RawConfigParser):
d = ConfigDict(self, section, vars)
if raw:
return [(option, d[option])
for option in options]
return [(option, d[option]) for option in options]
else:
return [(option, self._interpolate(section, option, d[option], d))
for option in options]
return [(option, self._interpolate(section, option, d[option], d)) for option in options]
class SafeConfigParser(ConfigParser):
_interpvar_re = re.compile(r"%\(([^)]+)\)s")
_badpercent_re = re.compile(r"%[^%]|%$")
def set(self, section, option, value):
if not isinstance(value, six.string_types):
def set(self, section: str, option: str, value: object) -> None:
if not isinstance(value, str):
raise TypeError("option values must be strings")
# check for bad percent signs:
# 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
m = self._badpercent_re.search(tmp_value)
if m:
raise ValueError("invalid interpolation syntax in %r at "
"position %d" % (value, m.start()))
raise ValueError("invalid interpolation syntax in %r at " "position %d" % (value, m.start()))
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
L = []
self._interpolate_some(option, L, rawval, section, vars, 1)
return ''.join(L)
return "".join(L)
_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:
raise InterpolationDepthError(option, section, rest)
while rest:
@@ -326,18 +344,14 @@ class SafeConfigParser(ConfigParser):
if m is None:
raise InterpolationSyntaxError(option, section, "bad interpolation variable reference %r" % rest)
var = m.group(1)
rest = rest[m.end():]
rest = rest[m.end() :]
try:
v = map[var]
except KeyError:
raise InterpolationMissingOptionError(
option, section, rest, var)
raise InterpolationMissingOptionError(option, section, rest, var)
if "%" in v:
self._interpolate_some(option, accum, v,
section, map, depth + 1)
self._interpolate_some(option, accum, v, section, map, depth + 1)
else:
accum.append(v)
else:
raise InterpolationSyntaxError(
option, section,
"'%' must be followed by '%' or '(', found: " + repr(rest))
raise InterpolationSyntaxError(option, section, "'%' must be followed by '%' or '(', found: " + repr(rest))
+70 -53
View File
@@ -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.
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,
and this class will automatically provide dotted access.
"""
# Methods that must be implemented by subclasses
def _getitem(self, key):
def _getitem(self, key: str) -> object:
return NotImplementedError(key)
def __setitem__(self, key, value):
def __setitem__(self, key: str, value: object):
raise NotImplementedError(key, value)
def __delitem__(self, key):
def __delitem__(self, key: str) -> None:
raise NotImplementedError(key)
def __iter__(self):
def __iter__(self) -> Iterable[str]:
# FIXME Raise instead return
return NotImplementedError()
def _new_namespace(self, name):
def _new_namespace(self, name: str) -> "ConfigNamespace":
raise NotImplementedError(name)
def __contains__(self, key):
def __contains__(self, key: str) -> bool:
try:
self._getitem(key)
except KeyError:
@@ -44,35 +50,35 @@ class ConfigNamespace(object):
#
# To distinguish between accesses of class members and namespace
# 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.
#
# Therefore, member variables should be defined in the class,
# not just in the __init__() function. See BasicNamespace for
# an example.
def __getitem__(self, key):
def __getitem__(self, key: str) -> Union[object, "Undefined"]:
try:
return self._getitem(key)
except KeyError:
return Undefined(key, self)
def __getattr__(self, name):
def __getattr__(self, name: str) -> Union[object, "Undefined"]:
try:
return self._getitem(name)
except KeyError:
if name.startswith('__') and name.endswith('__'):
if name.startswith("__") and name.endswith("__"):
raise AttributeError
return Undefined(name, self)
def __setattr__(self, name, value):
def __setattr__(self, name: str, value: object) -> None:
try:
object.__getattribute__(self, name)
object.__setattr__(self, name, value)
except AttributeError:
self.__setitem__(name, value)
def __delattr__(self, name):
def __delattr__(self, name: str) -> None:
try:
object.__getattribute__(self, name)
object.__delattr__(self, name)
@@ -82,12 +88,12 @@ class ConfigNamespace(object):
# During unpickling, Python checks if the class has a __setstate__
# method. But, the data dicts have not been initialised yet, which
# leads to _getitem and hence __getattr__ raising an exception. So
# we explicitly impement default __setstate__ behavior.
def __setstate__(self, state):
# we explicitly implement default __setstate__ behavior.
def __setstate__(self, state: dict) -> None:
self.__dict__.update(state)
class Undefined(object):
class Undefined:
"""Helper class used to hold undefined names until assignment.
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.
"""
def __init__(self, name, namespace):
object.__setattr__(self, 'name', name)
object.__setattr__(self, 'namespace', namespace)
def __init__(self, name: str, namespace: ConfigNamespace):
# FIXME These assignments into `object` feel very strange.
# 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[name] = value
def __setitem__(self, name, value):
def __setitem__(self, name, value) -> None:
obj = self.namespace._new_namespace(self.name)
obj[name] = value
# ---- Basic implementation of a ConfigNamespace
class BasicConfig(ConfigNamespace):
"""Represents a hierarchical collection of named values.
@@ -161,7 +170,7 @@ class BasicConfig(ConfigNamespace):
Finally, values can be read from a file as follows:
>>> from six import StringIO
>>> from io import StringIO
>>> sio = StringIO('''
... # comment
... ui.height = 100
@@ -181,66 +190,73 @@ class BasicConfig(ConfigNamespace):
"""
# this makes sure that __setattr__ knows this is not a namespace key
_data = None
_data: Dict[str, str] = None
def __init__(self):
self._data = {}
def _getitem(self, key):
def _getitem(self, key: str) -> str:
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
def __delitem__(self, key):
def __delitem__(self, key: str) -> None:
del self._data[key]
def __iter__(self):
def __iter__(self) -> Iterable[str]:
return iter(self._data)
def __str__(self, prefix=''):
lines = []
keys = list(self._data.keys())
def __str__(self, prefix: str = "") -> str:
lines: List[str] = []
keys: List[str] = list(self._data.keys())
keys.sort()
for name in keys:
value = self._data[name]
value: object = self._data[name]
if isinstance(value, ConfigNamespace):
lines.append(value.__str__(prefix='%s%s.' % (prefix,name)))
lines.append(value.__str__(prefix="%s%s." % (prefix, name)))
else:
if value is None:
lines.append('%s%s' % (prefix, name))
lines.append("%s%s" % (prefix, name))
else:
lines.append('%s%s = %s' % (prefix, name, value))
return '\n'.join(lines)
lines.append("%s%s = %s" % (prefix, name, value))
return "\n".join(lines)
def _new_namespace(self, name):
def _new_namespace(self, name: str) -> "BasicConfig":
obj = BasicConfig()
self._data[name] = obj
return obj
def _readfp(self, fp):
def _readfp(self, fp: TextIO) -> None:
while True:
line = fp.readline()
line: str = fp.readline()
if not line:
break
line = line.strip()
if not line: continue
if line[0] == '#': continue
data = line.split('=', 1)
if not line:
continue
if line[0] == "#":
continue
data: List[str] = line.split("=", 1)
if len(data) == 1:
name = line
value = None
else:
name = data[0].strip()
value = data[1].strip()
name_components = name.split('.')
ns = self
name_components = name.split(".")
ns: ConfigNamespace = self
for n in name_components[:-1]:
if n in ns:
ns = ns[n]
if not isinstance(ns, ConfigNamespace):
raise TypeError('value-namespace conflict', n)
maybe_ns: object = ns[n]
if not isinstance(maybe_ns, ConfigNamespace):
raise TypeError("value-namespace conflict", n)
ns = maybe_ns
else:
ns = ns._new_namespace(n)
ns[name_components[-1]] = value
@@ -248,7 +264,8 @@ class BasicConfig(ConfigNamespace):
# ---- Utility functions
def update_config(target, source):
def update_config(target: ConfigNamespace, source: ConfigNamespace):
"""Imports values from source into target.
Recursively walks the <source> ConfigNamespace and inserts values
@@ -276,15 +293,15 @@ def update_config(target, source):
display_clock = True
display_qlength = True
width = 150
"""
for name in sorted(source):
value = source[name]
value: object = source[name]
if isinstance(value, ConfigNamespace):
if name in target:
myns = target[name]
if not isinstance(myns, ConfigNamespace):
raise TypeError('value-namespace conflict')
maybe_myns: object = target[name]
if not isinstance(maybe_myns, ConfigNamespace):
raise TypeError("value-namespace conflict")
myns = maybe_myns
else:
myns = target._new_namespace(name)
update_config(myns, value)
+2 -7
View File
@@ -1,7 +1,2 @@
try:
from ConfigParser import *
# not all objects get imported with __all__
from ConfigParser import Error, InterpolationMissingOptionError
except ImportError:
from configparser import *
from configparser import Error, InterpolationMissingOptionError
from configparser import *
from configparser import Error, InterpolationMissingOptionError
+236 -202
View File
@@ -7,7 +7,7 @@
Example:
>>> from six import StringIO
>>> from io import StringIO
>>> sio = StringIO('''# configure foo-application
... [foo]
... bar1 = qualia
@@ -39,26 +39,31 @@ Example:
# An ini parser that supports ordered sections/options
# Also supports updates, while preserving structure
# Backward-compatiable with ConfigParser
# Backward-compatible with ConfigParser
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
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:
self.line = line.strip('\n')
self.line = line.strip("\n")
# Return the original line for unmodified objects
# Otherwise construct using the current attribute values
def __str__(self):
def __str__(self) -> str:
if self.line is not None:
return self.line
else:
@@ -66,78 +71,87 @@ class LineType(object):
# If an attribute is modified after initialization
# set line to None since it is no longer accurate.
def __setattr__(self, name, value):
if hasattr(self,name):
self.__dict__['line'] = None
def __setattr__(self, name: str, value: object) -> None:
if hasattr(self, name):
self.__dict__["line"] = None
self.__dict__[name] = value
def to_string(self):
raise Exception('This method must be overridden in derived classes')
def to_string(self) -> str:
# FIXME Raise NotImplementedError instead
raise Exception("This method must be overridden in derived classes")
class SectionLine(LineType):
regex = re.compile(r'^\['
r'(?P<name>[^]]+)'
r'\]\s*'
r'((?P<csep>;|#)(?P<comment>.*))?$')
regex = re.compile(r"^\[" r"(?P<name>[^]]+)" r"\]\s*" r"((?P<csep>;|#)(?P<comment>.*))?$")
def __init__(self, name, comment=None, comment_separator=None,
comment_offset=-1, line=None):
super(SectionLine, self).__init__(line)
self.name = name
self.comment = comment
self.comment_separator = comment_separator
self.comment_offset = comment_offset
def __init__(
self,
name: str,
comment: Optional[str] = None,
comment_separator: Optional[str] = None,
comment_offset: int = -1,
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):
out = '[' + self.name + ']'
def to_string(self) -> str:
out: str = "[" + self.name + "]"
if self.comment is not None:
# 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
return out
def parse(cls, line):
m = cls.regex.match(line.rstrip())
@classmethod
def parse(cls, line: str) -> Optional["SectionLine"]:
m: Optional[re.Match] = cls.regex.match(line.rstrip())
if m is None:
return None
return cls(m.group('name'), m.group('comment'),
m.group('csep'), m.start('csep'),
line)
parse = classmethod(parse)
return cls(m.group("name"), m.group("comment"), m.group("csep"), m.start("csep"), line)
class OptionLine(LineType):
def __init__(self, name, value, separator=' = ', comment=None,
comment_separator=None, comment_offset=-1, line=None):
super(OptionLine, self).__init__(line)
self.name = name
self.value = value
self.separator = separator
self.comment = comment
self.comment_separator = comment_separator
self.comment_offset = comment_offset
def __init__(
self,
name: str,
value: object,
separator: str = " = ",
comment: Optional[str] = None,
comment_separator: Optional[str] = None,
comment_offset: int = -1,
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):
out = '%s%s%s' % (self.name, self.separator, self.value)
def to_string(self) -> str:
out: str = "%s%s%s" % (self.name, self.separator, self.value)
if self.comment is not None:
# 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
return out
regex = re.compile(r'^(?P<name>[^:=\s[][^:=]*)'
r'(?P<sep>[:=]\s*)'
r'(?P<value>.*)$')
regex = re.compile(r"^(?P<name>[^:=\s[][^:=]*)" r"(?P<sep>[:=]\s*)" r"(?P<value>.*)$")
def parse(cls, line):
m = cls.regex.match(line.rstrip())
@classmethod
def parse(cls, line: str) -> Optional["OptionLine"]:
m: Optional[re.Match] = cls.regex.match(line.rstrip())
if m is None:
return None
name = m.group('name').rstrip()
value = m.group('value')
sep = m.group('name')[len(name):] + m.group('sep')
name: str = m.group("name").rstrip()
value: str = m.group("value")
sep: str = m.group("name")[len(name) :] + m.group("sep")
# comments are not detected in the regex because
# ensuring total compatibility with ConfigParser
@@ -150,123 +164,120 @@ class OptionLine(LineType):
# include ';' in the value needs to be addressed.
# Also, '#' doesn't mark comments in options...
coff = value.find(';')
if coff != -1 and value[coff-1].isspace():
comment = value[coff+1:]
coff: int = value.find(";")
if coff != -1 and value[coff - 1].isspace():
comment = value[coff + 1 :]
csep = value[coff]
value = value[:coff].rstrip()
coff = m.start('value') + coff
coff = m.start("value") + coff
else:
comment = None
csep = None
coff = -1
return cls(name, value, sep, comment, csep, coff, line)
parse = classmethod(parse)
def change_comment_syntax(comment_chars='%;#', allow_rem=False):
comment_chars = re.sub(r'([\]\-\^])', r'\\\1', comment_chars)
regex = r'^(?P<csep>[%s]' % comment_chars
def change_comment_syntax(comment_chars: str = "%;#", allow_rem: bool = False) -> None:
comment_chars: str = re.sub(r"([\]\-\^])", r"\\\1", comment_chars)
regex: str = r"^(?P<csep>[%s]" % comment_chars
if allow_rem:
regex += '|[rR][eE][mM]'
regex += r')(?P<comment>.*)$'
regex += "|[rR][eE][mM]"
regex += r")(?P<comment>.*)$"
CommentLine.regex = re.compile(regex)
class CommentLine(LineType):
regex = re.compile(r'^(?P<csep>[;#])'
r'(?P<comment>.*)$')
regex: re.Pattern = re.compile(r"^(?P<csep>[;#]|[rR][eE][mM])" r"(?P<comment>.*)$")
def __init__(self, comment='', separator='#', line=None):
super(CommentLine, self).__init__(line)
self.comment = comment
self.separator = separator
def __init__(self, comment: str = "", separator: str = "#", line: Optional[str] = None) -> None:
super().__init__(line)
self.comment: str = comment
self.separator: str = separator
def to_string(self):
def to_string(self) -> str:
return self.separator + self.comment
def parse(cls, line):
m = cls.regex.match(line.rstrip())
@classmethod
def parse(cls, line: str) -> Optional["CommentLine"]:
m: Optional[re.Match] = cls.regex.match(line.rstrip())
if m is None:
return None
return cls(m.group('comment'), m.group('csep'), line)
parse = classmethod(parse)
return cls(m.group("comment"), m.group("csep"), line)
class EmptyLine(LineType):
# could make this a singleton
def to_string(self):
return ''
def to_string(self) -> str:
return ""
value = property(lambda self: '')
value = property(lambda self: "")
def parse(cls, line):
@classmethod
def parse(cls, line: str) -> Optional["EmptyLine"]:
if line.strip():
return None
return cls(line)
parse = classmethod(parse)
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):
super(ContinuationLine, self).__init__(line)
def __init__(self, value: str, value_offset: Optional[int] = None, line: Optional[str] = None) -> None:
super().__init__(line)
self.value = value
if value_offset is None:
value_offset = 8
self.value_offset = value_offset
self.value_offset: int = value_offset
def to_string(self):
return ' '*self.value_offset + self.value
def to_string(self) -> str:
return " " * self.value_offset + self.value
def parse(cls, line):
m = cls.regex.match(line.rstrip())
@classmethod
def parse(cls, line: str) -> Optional["ContinuationLine"]:
m: Optional[re.Match] = cls.regex.match(line.rstrip())
if m is None:
return None
return cls(m.group('value'), m.start('value'), line)
parse = classmethod(parse)
return cls(m.group("value"), m.start("value"), line)
class LineContainer(object):
def __init__(self, d=None):
class LineContainer:
def __init__(self, d: Optional[Union[List[LineType], LineType]] = None) -> None:
self.contents = []
self.orgvalue = None
self.orgvalue: str = None
if d:
if isinstance(d, list): self.extend(d)
else: self.add(d)
if isinstance(d, list):
self.extend(d)
else:
self.add(d)
def add(self, x):
def add(self, x: LineType) -> None:
self.contents.append(x)
def extend(self, x):
for i in x: self.add(i)
def extend(self, x: List[LineType]) -> None:
for i in x:
self.add(i)
def get_name(self):
def get_name(self) -> str:
return self.contents[0].name
def set_name(self, data):
def set_name(self, data: str) -> None:
self.contents[0].name = data
def get_value(self):
def get_value(self) -> str:
if self.orgvalue is not None:
return self.orgvalue
elif len(self.contents) == 1:
return self.contents[0].value
else:
return '\n'.join([('%s' % x.value) for x in self.contents
if not isinstance(x, CommentLine)])
return "\n".join([("%s" % x.value) for x in self.contents if not isinstance(x, CommentLine)])
def set_value(self, data):
def set_value(self, data: object) -> None:
self.orgvalue = data
lines = ('%s' % data).split('\n')
lines: List[str] = ("%s" % data).split("\n")
# If there is an existing ContinuationLine, use its offset
value_offset = None
value_offset: Optional[int] = None
for v in self.contents:
if isinstance(v, ContinuationLine):
value_offset = v.value_offset
@@ -282,40 +293,45 @@ class LineContainer(object):
else:
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)
value = property(get_value, set_value)
def __str__(self):
s = [x.__str__() for x in self.contents]
return '\n'.join(s)
line_number = property(get_line_number)
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]:
if hasattr(x, 'name') and x.name==key:
if hasattr(x, "name") and x.name == key:
yield x
def find(self, key):
def find(self, key: str) -> Union[SectionLine, OptionLine]:
for x in self.finditer(key):
return x
raise KeyError(key)
def _make_xform_property(myattrname, srcattrname=None):
private_attrname = myattrname + 'value'
private_srcname = myattrname + 'source'
def _make_xform_property(myattrname: str, srcattrname: Optional[str] = None) -> property:
private_attrname: str = myattrname + "value"
private_srcname: str = myattrname + "source"
if srcattrname is None:
srcattrname = myattrname
def getfn(self):
srcobj = getattr(self, private_srcname)
def getfn(self) -> Callable:
srcobj: Optional[object] = getattr(self, private_srcname)
if srcobj is not None:
return getattr(srcobj, srcattrname)
else:
return getattr(self, private_attrname)
def setfn(self, value):
srcobj = getattr(self, private_srcname)
def setfn(self, value: Callable) -> None:
srcobj: Optional[object] = getattr(self, private_srcname)
if srcobj is not None:
setattr(srcobj, srcattrname, value)
else:
@@ -325,31 +341,38 @@ def _make_xform_property(myattrname, srcattrname=None):
class INISection(config.ConfigNamespace):
_lines = None
_options = None
_defaults = None
_optionxformvalue = None
_optionxformsource = None
_compat_skip_empty_lines = set()
_lines: List[LineContainer] = None
_options: Dict[str, object] = None
_defaults: Optional["INISection"] = None
_optionxformvalue: "INIConfig" = None
_optionxformsource: "INIConfig" = None
_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._defaults = defaults
self._optionxformvalue = optionxformvalue
self._optionxformsource = optionxformsource
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
# is checked for backward-compatible handling
if key == '__name__':
if key == "__name__":
return self._lines[-1].name
if self._optionxform: key = self._optionxform(key)
if self._optionxform:
key = self._optionxform(key)
try:
value = self._options[key].value
del_empty = key in self._compat_skip_empty_lines
value: str = self._options[key].value
del_empty: bool = key in self._compat_skip_empty_lines
except KeyError:
if self._defaults and key in self._defaults._options:
value = self._defaults._options[key].value
@@ -357,13 +380,14 @@ class INISection(config.ConfigNamespace):
else:
raise
if del_empty:
value = re.sub('\n+', '\n', value)
value = re.sub("\n+", "\n", value)
return value
def _getitem(self, key):
if key == '__name__':
def _getitem(self, key: str) -> object:
if key == "__name__":
return self._lines[-1].name
if self._optionxform: key = self._optionxform(key)
if self._optionxform:
key = self._optionxform(key)
try:
return self._options[key].value
except KeyError:
@@ -372,22 +396,25 @@ class INISection(config.ConfigNamespace):
else:
raise
def __setitem__(self, key, value):
if self._optionxform: xkey = self._optionxform(key)
else: xkey = key
def __setitem__(self, key: str, value: object) -> None:
if self._optionxform:
xkey = self._optionxform(key)
else:
xkey = key
if xkey in self._compat_skip_empty_lines:
self._compat_skip_empty_lines.remove(xkey)
if xkey not in self._options:
# create a dummy object - value may have multiple lines
obj = LineContainer(OptionLine(key, ''))
obj = LineContainer(OptionLine(key, ""))
self._lines[-1].add(obj)
self._options[xkey] = obj
# the set_value() function in LineContainer
# automatically handles multi-line values
self._options[xkey].value = value
def __delitem__(self, key):
if self._optionxform: key = self._optionxform(key)
def __delitem__(self, key: str) -> None:
if self._optionxform:
key = self._optionxform(key)
if key in self._compat_skip_empty_lines:
self._compat_skip_empty_lines.remove(key)
for l in self._lines:
@@ -395,14 +422,16 @@ class INISection(config.ConfigNamespace):
for o in l.contents:
if isinstance(o, LineContainer):
n = o.name
if self._optionxform: n = self._optionxform(n)
if key != n: remaining.append(o)
if self._optionxform:
n = self._optionxform(n)
if key != n:
remaining.append(o)
else:
remaining.append(o)
l.contents = remaining
del self._options[key]
def __iter__(self):
def __iter__(self) -> Iterator[str]:
d = set()
for l in self._lines:
for x in l.contents:
@@ -421,26 +450,25 @@ class INISection(config.ConfigNamespace):
d.add(x)
def _new_namespace(self, name):
raise Exception('No sub-sections allowed', name)
raise Exception("No sub-sections allowed", name)
def make_comment(line):
return CommentLine(line.rstrip('\n'))
def make_comment(line: str) -> CommentLine:
return CommentLine(line.rstrip("\n"))
def readline_iterator(f):
"""iterate over a file by only using the file object's readline method"""
have_newline = False
def readline_iterator(f: TextIO) -> Iterator[str]:
"""Iterate over a file by only using the file object's readline method."""
have_newline: bool = False
while True:
line = f.readline()
line: Optional[str] = f.readline()
if not line:
if have_newline:
yield ""
return
if line.endswith('\n'):
if line.endswith("\n"):
have_newline = True
else:
have_newline = False
@@ -448,57 +476,67 @@ def readline_iterator(f):
yield line
def lower(x):
def lower(x: str) -> str:
return x.lower()
class INIConfig(config.ConfigNamespace):
_data = None
_sections = None
_defaults = None
_optionxformvalue = None
_optionxformsource = None
_sectionxformvalue = None
_sectionxformsource = None
_data: LineContainer = None
_sections: Dict[str, object] = None
_defaults: INISection = None
_optionxformvalue: Callable = None
_optionxformsource: Optional["INIConfig"] = None
_sectionxformvalue: Optional["INIConfig"] = None
_sectionxformsource: Optional["INIConfig"] = None
_parse_exc = None
_bom = False
def __init__(self, fp=None, defaults=None, parse_exc=True,
optionxformvalue=lower, optionxformsource=None,
sectionxformvalue=None, sectionxformsource=None):
def __init__(
self,
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._parse_exc = parse_exc
self._optionxformvalue = optionxformvalue
self._optionxformsource = optionxformsource
self._sectionxformvalue = sectionxformvalue
self._sectionxformsource = sectionxformsource
self._sections = {}
if defaults is None: defaults = {}
self._sections: Dict[str, INISection] = {}
if defaults is None:
defaults = {}
self._defaults = INISection(LineContainer(), optionxformsource=self)
for name, value in defaults.items():
self._defaults[name] = value
if fp is not None:
self._readfp(fp)
_optionxform = _make_xform_property('_optionxform', 'optionxform')
_sectionxform = _make_xform_property('_sectionxform', 'optionxform')
_optionxform = _make_xform_property("_optionxform", "optionxform")
_sectionxform = _make_xform_property("_sectionxform", "optionxform")
def _getitem(self, key):
def _getitem(self, key: str) -> INISection:
if key == DEFAULTSECT:
return self._defaults
if self._sectionxform: key = self._sectionxform(key)
if self._sectionxform:
key = self._sectionxform(key)
return self._sections[key]
def __setitem__(self, key, value):
raise Exception('Values must be inside sections', key, value)
def __setitem__(self, key: str, value: object):
raise Exception("Values must be inside sections", key, value)
def __delitem__(self, key):
if self._sectionxform: key = self._sectionxform(key)
def __delitem__(self, key: str) -> None:
if self._sectionxform:
key = self._sectionxform(key)
for line in self._sections[key]._lines:
self._data.contents.remove(line)
del self._sections[key]
def __iter__(self):
def __iter__(self) -> Iterator[str]:
d = set()
d.add(DEFAULTSECT)
for x in self._data.contents:
@@ -507,35 +545,31 @@ class INIConfig(config.ConfigNamespace):
yield x.name
d.add(x.name)
def _new_namespace(self, name):
def _new_namespace(self, name: str) -> INISection:
if self._data.contents:
self._data.add(EmptyLine())
obj = LineContainer(SectionLine(name))
self._data.add(obj)
if self._sectionxform: name = self._sectionxform(name)
if self._sectionxform:
name = self._sectionxform(name)
if name in self._sections:
ns = self._sections[name]
ns._lines.append(obj)
else:
ns = INISection(obj, defaults=self._defaults,
optionxformsource=self)
ns = INISection(obj, defaults=self._defaults, optionxformsource=self)
self._sections[name] = ns
return ns
def __str__(self):
def __str__(self) -> str:
if self._bom:
fmt = u'\ufeff%s'
fmt = "\ufeff%s"
else:
fmt = '%s'
fmt = "%s"
return fmt % self._data.__str__()
__unicode__ = __str__
_line_types = [EmptyLine, CommentLine, SectionLine, OptionLine, ContinuationLine]
_line_types = [EmptyLine, CommentLine,
SectionLine, OptionLine,
ContinuationLine]
def _parse(self, line):
def _parse(self, line: str) -> Any:
for linetype in self._line_types:
lineobj = linetype.parse(line)
if lineobj:
@@ -544,7 +578,7 @@ class INIConfig(config.ConfigNamespace):
# can't parse line
return None
def _readfp(self, fp):
def _readfp(self, fp: TextIO) -> None:
cur_section = None
cur_option = None
cur_section_name = None
@@ -554,21 +588,20 @@ class INIConfig(config.ConfigNamespace):
try:
fname = fp.name
except AttributeError:
fname = '<???>'
fname = "<???>"
line_count = 0
exc = None
line = None
for line in readline_iterator(fp):
# Check for BOM on first line
if line_count == 0 and isinstance(line, six.text_type):
if line[0] == u'\ufeff':
if line_count == 0 and isinstance(line, str):
if line[0] == "\ufeff":
line = line[1:]
self._bom = True
line_obj = self._parse(line)
line_count += 1
if not cur_section and not isinstance(line_obj, (CommentLine, EmptyLine, SectionLine)):
if self._parse_exc:
raise MissingSectionHeaderError(fname, line_count, line)
@@ -588,7 +621,7 @@ class INIConfig(config.ConfigNamespace):
cur_option.extend(pending_lines)
pending_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
cur_option.add(line_obj)
else:
@@ -633,9 +666,7 @@ class INIConfig(config.ConfigNamespace):
else:
cur_section_name = cur_section.name
if cur_section_name not in self._sections:
self._sections[cur_section_name] = \
INISection(cur_section, defaults=self._defaults,
optionxformsource=self)
self._sections[cur_section_name] = INISection(cur_section, defaults=self._defaults, optionxformsource=self)
else:
self._sections[cur_section_name]._lines.append(cur_section)
@@ -644,8 +675,11 @@ class INIConfig(config.ConfigNamespace):
if isinstance(line_obj, EmptyLine):
pending_empty_lines = True
if line_obj:
line_obj.line_number = line_count
self._data.extend(pending_lines)
if line and line[-1] == '\n':
if line and line[-1] == "\n":
self._data.add(EmptyLine())
if exc:
+12 -8
View File
@@ -1,8 +1,13 @@
from typing import TYPE_CHECKING, List
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.
This functions makes the configuration look clean and
@@ -19,8 +24,7 @@ def tidy(cfg):
if isinstance(cont[i], LineContainer):
tidy_section(cont[i])
i += 1
elif (isinstance(cont[i-1], EmptyLine) and
isinstance(cont[i], EmptyLine)):
elif isinstance(cont[i - 1], EmptyLine) and isinstance(cont[i], EmptyLine):
del cont[i]
else:
i += 1
@@ -34,11 +38,11 @@ def tidy(cfg):
cont.append(EmptyLine())
def tidy_section(lc):
cont = lc.contents
i = 1
def tidy_section(lc: "LineContainer"):
cont: List[LineType] = lc.contents
i: int = 1
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]
else:
i += 1
+97
View File
@@ -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)
+104
View File
@@ -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)
+222
View File
@@ -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)
+20
View File
@@ -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
View File
@@ -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
View File
@@ -1,3 +1,15 @@
six
requests
paramiko
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
+1 -2
View File
@@ -7,9 +7,8 @@ mkdir -p BUILD RPMS
VERSION=`git rev-list HEAD --count`
VERSION=`git rev-list HEAD --count`
echo $VERSION > __VERSION__
rpmbuild -bb --buildroot $PWD/builddir -v --clean tis-tisbackup.spec
cp RPMS/*/*.rpm .
+1 -1
View File
@@ -15,7 +15,7 @@ Source0: ../
Prefix: /
%if "%{rhel}" == "8"
Requires: unzip rsync python3-paramiko python3-pyvmomi nfs-utils python3-flask python3-simplejson autofs python3-pexpect
Requires: unzip rsync python3-paramiko python3-pyvmomi nfs-utils python3-flask python3-simplejson autofs python3-pexpect
%endif
%if "%{rhel}" == "7"
Requires: unzip rsync python36-paramiko python3-pyvmomi nfs-utils python3-flask python3-simplejson autofs pexpect

Some files were not shown because too many files have changed in this diff Show More